Signed-off-by: brookjeynes me@brookjeynes.dev
+6
cmd/server/main.go
+6
cmd/server/main.go
···
2
2
3
3
import (
4
4
"context"
5
+
"log"
5
6
"log/slog"
6
7
"net/http"
8
+
"os"
7
9
8
10
"shelf.app/internal/config"
9
11
"shelf.app/internal/server"
···
23
25
slog.Error("failed to close state", "err", err)
24
26
}
25
27
}()
28
+
if err != nil {
29
+
log.Fatalf("failed to start server: %v", err)
30
+
os.Exit(-1)
31
+
}
26
32
27
33
slog.Info("Starting server", "addr", config.Core.ListenAddr)
28
34
+21
-1
go.mod
+21
-1
go.mod
···
3
3
go 1.25
4
4
5
5
require (
6
-
github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e
6
+
github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab
7
7
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
8
8
)
9
9
10
10
require (
11
11
github.com/a-h/templ v0.3.977 // indirect
12
+
github.com/beorn7/perks v1.0.1 // indirect
13
+
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
14
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
15
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
16
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
17
+
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
18
+
github.com/google/go-querystring v1.1.0 // indirect
19
+
github.com/gorilla/securecookie v1.1.2 // indirect
20
+
github.com/gorilla/sessions v1.4.0 // indirect
21
+
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
22
+
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
23
+
github.com/prometheus/client_golang v1.17.0 // indirect
24
+
github.com/prometheus/client_model v0.5.0 // indirect
25
+
github.com/prometheus/common v0.45.0 // indirect
26
+
github.com/prometheus/procfs v0.12.0 // indirect
27
+
github.com/redis/go-redis/v9 v9.18.0 // indirect
12
28
github.com/tdewolff/minify/v2 v2.24.8 // indirect
13
29
github.com/tdewolff/parse/v2 v2.8.5 // indirect
30
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
31
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
32
+
golang.org/x/time v0.3.0 // indirect
33
+
google.golang.org/protobuf v1.33.0 // indirect
14
34
)
15
35
16
36
require (
+45
go.sum
+45
go.sum
···
2
2
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
3
3
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
4
4
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
5
+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
6
+
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
5
7
github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e h1:dEM6bfzMfkRI39GLinuhQan47HzdrkqIzJkl/zRvz8s=
6
8
github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e/go.mod h1:KIy0FgNQacp4uv2Z7xhNkV3qZiUSGuRky97s7Pa4v+o=
9
+
github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab h1:Cs35T2tAN3Q6mMH5mBaY09nmCNOn/GkZS1F7jfMxlR8=
10
+
github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag=
11
+
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
12
+
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
13
+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
14
+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
7
15
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
8
16
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
9
17
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
10
18
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11
19
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
12
20
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
21
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
22
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
23
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
24
+
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
13
25
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
14
26
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
15
27
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
···
22
34
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
23
35
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
24
36
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
37
+
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
38
+
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
39
+
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
40
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
25
41
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
26
42
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
43
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
44
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
27
45
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
28
46
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
29
47
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
30
48
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
31
49
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
50
+
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
51
+
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
52
+
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
53
+
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
32
54
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
33
55
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
34
56
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
37
59
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
38
60
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
39
61
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
62
+
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
63
+
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
40
64
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
41
65
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
42
66
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
···
88
112
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
89
113
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
90
114
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
115
+
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
116
+
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
91
117
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
92
118
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
93
119
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
···
113
139
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
114
140
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
115
141
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
142
+
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
143
+
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
144
+
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
145
+
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
146
+
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
147
+
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
148
+
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
149
+
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
150
+
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
151
+
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
116
152
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
117
153
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
118
154
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
···
155
191
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
156
192
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
157
193
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
194
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
195
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
196
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
197
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
158
198
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
159
199
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
160
200
go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
···
217
257
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
218
258
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
219
259
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
260
+
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
220
261
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
221
262
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
222
263
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
223
264
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
265
+
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
266
+
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
224
267
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
225
268
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
226
269
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
240
283
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
241
284
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
242
285
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
286
+
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
287
+
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
243
288
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
244
289
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
245
290
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+60
internal/atproto/resolver.go
+60
internal/atproto/resolver.go
···
1
+
package atproto
2
+
3
+
import (
4
+
"context"
5
+
"net"
6
+
"net/http"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/identity"
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/carlmjohnson/versioninfo"
12
+
)
13
+
14
+
type Resolver struct {
15
+
directory identity.Directory
16
+
}
17
+
18
+
func DefaultResolver() *Resolver {
19
+
return &Resolver{
20
+
directory: identity.DefaultDirectory(),
21
+
}
22
+
}
23
+
24
+
func BaseDirectory() identity.Directory {
25
+
base := identity.BaseDirectory{
26
+
PLCURL: identity.DefaultPLCURL,
27
+
HTTPClient: http.Client{
28
+
Timeout: time.Second * 10,
29
+
Transport: &http.Transport{
30
+
// would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad.
31
+
IdleConnTimeout: time.Millisecond * 1000,
32
+
MaxIdleConns: 100,
33
+
},
34
+
},
35
+
Resolver: net.Resolver{
36
+
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
37
+
d := net.Dialer{Timeout: time.Second * 3}
38
+
return d.DialContext(ctx, network, address)
39
+
},
40
+
},
41
+
TryAuthoritativeDNS: true,
42
+
// Primary Bluesky PDS instance only supports HTTP resolution method.
43
+
SkipDNSDomainSuffixes: []string{".bsky.social"},
44
+
UserAgent: "indigo-identity/" + versioninfo.Short(),
45
+
}
46
+
return &base
47
+
}
48
+
49
+
func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
50
+
id, err := syntax.ParseAtIdentifier(arg)
51
+
if err != nil {
52
+
return nil, err
53
+
}
54
+
55
+
return r.directory.Lookup(ctx, id)
56
+
}
57
+
58
+
func (r *Resolver) Directory() identity.Directory {
59
+
return r.directory
60
+
}
+14
internal/cache/cache.go
+14
internal/cache/cache.go
+195
internal/cache/session/store.go
+195
internal/cache/session/store.go
···
1
+
// MIT License
2
+
//
3
+
// Copyright (c) 2025 Anirudh Oppiliappan, Akshay Oppiliappan and
4
+
// contributors.
5
+
//
6
+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7
+
// of this software and associated documentation files (the "Software"), to deal
8
+
// in the Software without restriction, including without limitation the rights
9
+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+
// copies of the Software, and to permit persons to whom the Software is
11
+
// furnished to do so, subject to the following conditions:
12
+
//
13
+
// The above copyright notice and this permission notice shall be included in all
14
+
// copies or substantial portions of the Software.
15
+
//
16
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+
// SOFTWARE.
23
+
24
+
package session
25
+
26
+
import (
27
+
"context"
28
+
"encoding/json"
29
+
"fmt"
30
+
"time"
31
+
32
+
"shelf.app/internal/cache"
33
+
)
34
+
35
+
type OAuthSession struct {
36
+
Handle string
37
+
Did string
38
+
PdsUrl string
39
+
AccessJwt string
40
+
RefreshJwt string
41
+
AuthServerIss string
42
+
DpopPdsNonce string
43
+
DpopAuthserverNonce string
44
+
DpopPrivateJwk string
45
+
Expiry string
46
+
}
47
+
48
+
type OAuthRequest struct {
49
+
AuthserverIss string
50
+
Handle string
51
+
State string
52
+
Did string
53
+
PdsUrl string
54
+
PkceVerifier string
55
+
DpopAuthserverNonce string
56
+
DpopPrivateJwk string
57
+
ReturnUrl string
58
+
}
59
+
60
+
type SessionStore struct {
61
+
cache *cache.Cache
62
+
}
63
+
64
+
const (
65
+
stateKey = "oauthstate:%s"
66
+
requestKey = "oauthrequest:%s"
67
+
sessionKey = "oauthsession:%s"
68
+
)
69
+
70
+
func New(cache *cache.Cache) *SessionStore {
71
+
return &SessionStore{cache: cache}
72
+
}
73
+
74
+
func (s *SessionStore) SaveSession(ctx context.Context, session OAuthSession) error {
75
+
key := fmt.Sprintf(sessionKey, session.Did)
76
+
data, err := json.Marshal(session)
77
+
if err != nil {
78
+
return err
79
+
}
80
+
81
+
// set with ttl (7 days)
82
+
ttl := 7 * 24 * time.Hour
83
+
84
+
return s.cache.Set(ctx, key, data, ttl).Err()
85
+
}
86
+
87
+
// SaveRequest stores the OAuth request to be later fetched in the callback. Since
88
+
// the fetching happens by comparing the state we get in the callback params, we
89
+
// store an additional state->did mapping which then lets us fetch the whole OAuth request.
90
+
func (s *SessionStore) SaveRequest(ctx context.Context, request OAuthRequest) error {
91
+
key := fmt.Sprintf(requestKey, request.Did)
92
+
data, err := json.Marshal(request)
93
+
if err != nil {
94
+
return err
95
+
}
96
+
97
+
// oauth flow must complete within 30 minutes
98
+
err = s.cache.Set(ctx, key, data, 30*time.Minute).Err()
99
+
if err != nil {
100
+
return fmt.Errorf("error saving request: %w", err)
101
+
}
102
+
103
+
stateKey := fmt.Sprintf(stateKey, request.State)
104
+
err = s.cache.Set(ctx, stateKey, request.Did, 30*time.Minute).Err()
105
+
if err != nil {
106
+
return fmt.Errorf("error saving state->did mapping: %w", err)
107
+
}
108
+
109
+
return nil
110
+
}
111
+
112
+
func (s *SessionStore) GetSession(ctx context.Context, did string) (*OAuthSession, error) {
113
+
key := fmt.Sprintf(sessionKey, did)
114
+
val, err := s.cache.Get(ctx, key).Result()
115
+
if err != nil {
116
+
return nil, err
117
+
}
118
+
119
+
var session OAuthSession
120
+
err = json.Unmarshal([]byte(val), &session)
121
+
if err != nil {
122
+
return nil, err
123
+
}
124
+
return &session, nil
125
+
}
126
+
127
+
func (s *SessionStore) GetRequestByState(ctx context.Context, state string) (*OAuthRequest, error) {
128
+
didKey, err := s.getRequestKeyFromState(ctx, state)
129
+
if err != nil {
130
+
return nil, err
131
+
}
132
+
133
+
val, err := s.cache.Get(ctx, didKey).Result()
134
+
if err != nil {
135
+
return nil, err
136
+
}
137
+
138
+
var request OAuthRequest
139
+
err = json.Unmarshal([]byte(val), &request)
140
+
if err != nil {
141
+
return nil, err
142
+
}
143
+
144
+
return &request, nil
145
+
}
146
+
147
+
func (s *SessionStore) DeleteSession(ctx context.Context, did string) error {
148
+
key := fmt.Sprintf(sessionKey, did)
149
+
return s.cache.Del(ctx, key).Err()
150
+
}
151
+
152
+
func (s *SessionStore) DeleteRequestByState(ctx context.Context, state string) error {
153
+
didKey, err := s.getRequestKeyFromState(ctx, state)
154
+
if err != nil {
155
+
return err
156
+
}
157
+
158
+
err = s.cache.Del(ctx, fmt.Sprintf(stateKey, state)).Err()
159
+
if err != nil {
160
+
return err
161
+
}
162
+
163
+
return s.cache.Del(ctx, didKey).Err()
164
+
}
165
+
166
+
func (s *SessionStore) RefreshSession(ctx context.Context, did, access, refresh, expiry string) error {
167
+
session, err := s.GetSession(ctx, did)
168
+
if err != nil {
169
+
return err
170
+
}
171
+
session.AccessJwt = access
172
+
session.RefreshJwt = refresh
173
+
session.Expiry = expiry
174
+
return s.SaveSession(ctx, *session)
175
+
}
176
+
177
+
func (s *SessionStore) UpdateNonce(ctx context.Context, did, nonce string) error {
178
+
session, err := s.GetSession(ctx, did)
179
+
if err != nil {
180
+
return err
181
+
}
182
+
session.DpopAuthserverNonce = nonce
183
+
return s.SaveSession(ctx, *session)
184
+
}
185
+
186
+
func (s *SessionStore) getRequestKeyFromState(ctx context.Context, state string) (string, error) {
187
+
key := fmt.Sprintf(stateKey, state)
188
+
did, err := s.cache.Get(ctx, key).Result()
189
+
if err != nil {
190
+
return "", err
191
+
}
192
+
193
+
didKey := fmt.Sprintf(requestKey, did)
194
+
return didKey, nil
195
+
}
+23
internal/config/config.go
+23
internal/config/config.go
···
2
2
3
3
import (
4
4
"context"
5
+
"fmt"
6
+
"net/url"
5
7
6
8
"github.com/sethvargo/go-envconfig"
7
9
)
···
18
20
ClientKid string `env:"CLIENT_KID"`
19
21
}
20
22
23
+
type RedisConfig struct {
24
+
Addr string `env:"ADDR, default=localhost:6379"`
25
+
Password string `env:"PASS"`
26
+
DB int `env:"DB, default=0"`
27
+
}
28
+
29
+
func (cfg RedisConfig) ToURL() string {
30
+
u := &url.URL{
31
+
Scheme: "redis",
32
+
Host: cfg.Addr,
33
+
Path: fmt.Sprintf("/%d", cfg.DB),
34
+
}
35
+
36
+
if cfg.Password != "" {
37
+
u.User = url.UserPassword("", cfg.Password)
38
+
}
39
+
40
+
return u.String()
41
+
}
42
+
21
43
type JetstreamConfig struct {
22
44
Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"`
23
45
}
···
26
48
Core CoreConfig `env:",prefix=SHELF_"`
27
49
Jetstream JetstreamConfig `env:",prefix=SHELF_JETSTREAM_"`
28
50
OAuth OAuthConfig `env:",prefix=SHELF_OAUTH_"`
51
+
Redis RedisConfig `env:",prefix=SHELF_REDIS_"`
29
52
}
30
53
31
54
func LoadConfig(ctx context.Context) (*Config, error) {
+17
-1
internal/server/login.go
+17
-1
internal/server/login.go
···
2
2
3
3
import (
4
4
"fmt"
5
+
"log/slog"
5
6
"net/http"
6
7
"strings"
7
8
···
45
46
return
46
47
}
47
48
48
-
htmx.HxRedirect(w, http.StatusOK, "/")
49
+
if err := s.oauth.SetAuthReturn(w, r, returnURL); err != nil {
50
+
slog.Error("failed to set auth return", "err", err)
51
+
}
52
+
53
+
redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle)
54
+
if err != nil {
55
+
w.Header().Set("Content-Type", "text/html")
56
+
login.LoginFormContent(login.LoginFormParams{
57
+
ReturnUrl: returnURL,
58
+
Handle: handle,
59
+
ErrorMessage: fmt.Sprintf("Failed to start auth flow: %v", err),
60
+
}).Render(r.Context(), w)
61
+
return
62
+
}
63
+
64
+
htmx.HxRedirect(w, http.StatusOK, redirectURL)
49
65
}
50
66
}
+45
internal/server/oauth/accounts.go
+45
internal/server/oauth/accounts.go
···
1
+
package oauth
2
+
3
+
import "net/http"
4
+
5
+
func (o *OAuth) SetAuthReturn(w http.ResponseWriter, r *http.Request, returnURL string) error {
6
+
session, err := o.SessionStore.Get(r, AuthReturnName)
7
+
if err != nil {
8
+
return err
9
+
}
10
+
11
+
session.Values[AuthReturnURL] = returnURL
12
+
session.Options.MaxAge = 60 * 30
13
+
session.Options.HttpOnly = true
14
+
session.Options.Secure = !o.Config.Core.Dev
15
+
session.Options.SameSite = http.SameSiteLaxMode
16
+
17
+
return session.Save(r, w)
18
+
}
19
+
20
+
type AuthReturnInfo struct {
21
+
ReturnURL string
22
+
}
23
+
24
+
func (o *OAuth) GetAuthReturn(r *http.Request) *AuthReturnInfo {
25
+
session, err := o.SessionStore.Get(r, AuthReturnName)
26
+
if err != nil || session.IsNew {
27
+
return &AuthReturnInfo{}
28
+
}
29
+
30
+
returnURL, _ := session.Values[AuthReturnURL].(string)
31
+
32
+
return &AuthReturnInfo{
33
+
ReturnURL: returnURL,
34
+
}
35
+
}
36
+
37
+
func (o *OAuth) ClearAuthReturn(w http.ResponseWriter, r *http.Request) error {
38
+
session, err := o.SessionStore.Get(r, AuthReturnName)
39
+
if err != nil {
40
+
return err
41
+
}
42
+
43
+
session.Options.MaxAge = -1
44
+
return session.Save(r, w)
45
+
}
+20
internal/server/oauth/consts.go
+20
internal/server/oauth/consts.go
···
1
+
package oauth
2
+
3
+
const (
4
+
ClientName = "Shlf"
5
+
ClientURI = "https://shlf.space"
6
+
SessionName = "shlf-oauth-session"
7
+
AuthReturnName = "shlf-auth-return"
8
+
AuthReturnURL = "return_url"
9
+
SessionHandle = "handle"
10
+
SessionDid = "did"
11
+
SessionId = "id"
12
+
SessionPds = "pds"
13
+
SessionAccessJwt = "accessJwt"
14
+
SessionRefreshJwt = "refreshJwt"
15
+
SessionExpiry = "expiry"
16
+
SessionAuthenticated = "authenticated"
17
+
18
+
SessionDpopPrivateJwk = "dpopPrivateJwk"
19
+
SessionDpopAuthServerNonce = "dpopAuthServerNonce"
20
+
)
+76
internal/server/oauth/handler.go
+76
internal/server/oauth/handler.go
···
1
+
package oauth
2
+
3
+
import (
4
+
"encoding/json"
5
+
"errors"
6
+
"fmt"
7
+
"net/http"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
10
+
"github.com/go-chi/chi/v5"
11
+
)
12
+
13
+
func (o *OAuth) Router() http.Handler {
14
+
r := chi.NewRouter()
15
+
16
+
r.Get("/oauth/client-metadata.json", o.clientMetadata)
17
+
r.Get("/oauth/jwks.json", o.jwks)
18
+
r.Get("/oauth/callback", o.callback)
19
+
20
+
return r
21
+
}
22
+
23
+
func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
24
+
clientName := ClientName
25
+
clientUri := ClientURI
26
+
27
+
meta := o.ClientApp.Config.ClientMetadata()
28
+
meta.JWKSURI = &o.JwksUri
29
+
meta.ClientName = &clientName
30
+
meta.ClientURI = &clientUri
31
+
32
+
w.Header().Set("Content-Type", "application/json")
33
+
if err := json.NewEncoder(w).Encode(meta); err != nil {
34
+
http.Error(w, err.Error(), http.StatusInternalServerError)
35
+
return
36
+
}
37
+
}
38
+
39
+
func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
40
+
w.Header().Set("Content-Type", "application/json")
41
+
body := o.ClientApp.Config.PublicJWKS()
42
+
if err := json.NewEncoder(w).Encode(body); err != nil {
43
+
http.Error(w, err.Error(), http.StatusInternalServerError)
44
+
return
45
+
}
46
+
}
47
+
48
+
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
49
+
ctx := r.Context()
50
+
51
+
authReturn := o.GetAuthReturn(r)
52
+
_ = o.ClearAuthReturn(w, r)
53
+
54
+
sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
55
+
if err != nil {
56
+
var callbackErr *oauth.AuthRequestCallbackError
57
+
if errors.As(err, &callbackErr) {
58
+
http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound)
59
+
return
60
+
}
61
+
http.Redirect(w, r, "/login?error=oauth", http.StatusFound)
62
+
return
63
+
}
64
+
65
+
if err := o.SaveSession(w, r, sessData); err != nil {
66
+
http.Redirect(w, r, "/login?error=session", http.StatusFound)
67
+
return
68
+
}
69
+
70
+
redirectURL := "/"
71
+
if authReturn.ReturnURL != "" {
72
+
redirectURL = authReturn.ReturnURL
73
+
}
74
+
75
+
http.Redirect(w, r, redirectURL, http.StatusFound)
76
+
}
+288
internal/server/oauth/oauth.go
+288
internal/server/oauth/oauth.go
···
1
+
package oauth
2
+
3
+
import (
4
+
"errors"
5
+
"fmt"
6
+
"net/http"
7
+
"time"
8
+
9
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
10
+
"github.com/bluesky-social/indigo/atproto/atclient"
11
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
12
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
xrpc "github.com/bluesky-social/indigo/xrpc"
15
+
"github.com/gorilla/sessions"
16
+
idresolver "shelf.app/internal/atproto"
17
+
"shelf.app/internal/config"
18
+
)
19
+
20
+
type OAuth struct {
21
+
ClientApp *oauth.ClientApp
22
+
SessionStore *sessions.CookieStore
23
+
Config *config.Config
24
+
JwksUri string
25
+
IdResolver *idresolver.Resolver
26
+
}
27
+
28
+
func New(config *config.Config, res *idresolver.Resolver) (*OAuth, error) {
29
+
var oauthConfig oauth.ClientConfig
30
+
var clientUri string
31
+
scope := []string{
32
+
"atproto",
33
+
}
34
+
35
+
if config.Core.Dev {
36
+
clientUri = "http://127.0.0.1:8080"
37
+
callbackUri := clientUri + "/oauth/callback"
38
+
oauthConfig = oauth.NewLocalhostConfig(callbackUri, scope)
39
+
} else {
40
+
clientUri = config.Core.Host
41
+
clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri)
42
+
callbackUri := clientUri + "/oauth/callback"
43
+
oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, scope)
44
+
}
45
+
46
+
// configure client secret
47
+
priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret)
48
+
if err != nil {
49
+
return nil, err
50
+
}
51
+
if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil {
52
+
return nil, err
53
+
}
54
+
55
+
jwksUri := clientUri + "/oauth/jwks.json"
56
+
57
+
authStore, err := NewRedisStore(&RedisStoreConfig{
58
+
RedisURL: config.Redis.ToURL(),
59
+
SessionExpiryDuration: time.Hour * 24 * 90,
60
+
SessionInactivityDuration: time.Hour * 24 * 14,
61
+
AuthRequestExpiryDuration: time.Minute * 30,
62
+
})
63
+
if err != nil {
64
+
return nil, err
65
+
}
66
+
67
+
sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
68
+
69
+
clientApp := oauth.NewClientApp(&oauthConfig, authStore)
70
+
clientApp.Dir = res.Directory()
71
+
// allow non-public transports in dev mode
72
+
if config.Core.Dev {
73
+
clientApp.Resolver.Client.Transport = http.DefaultTransport
74
+
}
75
+
76
+
return &OAuth{
77
+
ClientApp: clientApp,
78
+
Config: config,
79
+
SessionStore: sessStore,
80
+
JwksUri: jwksUri,
81
+
IdResolver: res,
82
+
}, nil
83
+
}
84
+
85
+
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error {
86
+
userSession, err := o.SessionStore.Get(r, SessionName)
87
+
if err != nil {
88
+
return err
89
+
}
90
+
91
+
userSession.Values[SessionDid] = sessData.AccountDID.String()
92
+
userSession.Values[SessionPds] = sessData.HostURL
93
+
userSession.Values[SessionId] = sessData.SessionID
94
+
userSession.Values[SessionAuthenticated] = true
95
+
96
+
if err := userSession.Save(r, w); err != nil {
97
+
return err
98
+
}
99
+
100
+
return nil
101
+
}
102
+
103
+
func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) {
104
+
userSession, err := o.SessionStore.Get(r, SessionName)
105
+
if err != nil {
106
+
return nil, fmt.Errorf("error getting user session: %w", err)
107
+
}
108
+
if userSession.IsNew {
109
+
return nil, fmt.Errorf("no session available for user")
110
+
}
111
+
112
+
d := userSession.Values[SessionDid].(string)
113
+
sessDid, err := syntax.ParseDID(d)
114
+
if err != nil {
115
+
return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
116
+
}
117
+
118
+
sessId := userSession.Values[SessionId].(string)
119
+
120
+
clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId)
121
+
if err != nil {
122
+
return nil, fmt.Errorf("failed to resume session: %w", err)
123
+
}
124
+
125
+
return clientSess, nil
126
+
}
127
+
128
+
func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error {
129
+
userSession, err := o.SessionStore.Get(r, SessionName)
130
+
if err != nil {
131
+
return fmt.Errorf("error getting user session: %w", err)
132
+
}
133
+
if userSession.IsNew {
134
+
return fmt.Errorf("no session available for user")
135
+
}
136
+
137
+
d := userSession.Values[SessionDid].(string)
138
+
sessDid, err := syntax.ParseDID(d)
139
+
if err != nil {
140
+
return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
141
+
}
142
+
143
+
sessId := userSession.Values[SessionId].(string)
144
+
145
+
// delete the session
146
+
err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId)
147
+
if err1 != nil {
148
+
err1 = fmt.Errorf("failed to logout: %w", err1)
149
+
}
150
+
151
+
// remove the cookie
152
+
userSession.Options.MaxAge = -1
153
+
err2 := o.SessionStore.Save(r, w, userSession)
154
+
if err2 != nil {
155
+
err2 = fmt.Errorf("failed to save into session store: %w", err2)
156
+
}
157
+
158
+
return errors.Join(err1, err2)
159
+
}
160
+
161
+
type User struct {
162
+
Did string
163
+
Pds string
164
+
}
165
+
166
+
func (o *OAuth) GetUser(r *http.Request) *User {
167
+
sess, err := o.ResumeSession(r)
168
+
if err != nil {
169
+
return nil
170
+
}
171
+
172
+
return &User{
173
+
Did: sess.Data.AccountDID.String(),
174
+
Pds: sess.Data.HostURL,
175
+
}
176
+
}
177
+
178
+
func (o *OAuth) GetDid(r *http.Request) string {
179
+
if u := o.GetUser(r); u != nil {
180
+
return u.Did
181
+
}
182
+
183
+
return ""
184
+
}
185
+
186
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*atclient.APIClient, error) {
187
+
session, err := o.ResumeSession(r)
188
+
if err != nil {
189
+
return nil, fmt.Errorf("error getting session: %w", err)
190
+
}
191
+
return session.APIClient(), nil
192
+
}
193
+
194
+
// this is a higher level abstraction on ServerGetServiceAuth
195
+
type ServiceClientOpts struct {
196
+
service string
197
+
exp int64
198
+
lxm string
199
+
dev bool
200
+
timeout time.Duration
201
+
}
202
+
203
+
type ServiceClientOpt func(*ServiceClientOpts)
204
+
205
+
func DefaultServiceClientOpts() ServiceClientOpts {
206
+
return ServiceClientOpts{
207
+
timeout: time.Second * 5,
208
+
}
209
+
}
210
+
211
+
func WithService(service string) ServiceClientOpt {
212
+
return func(s *ServiceClientOpts) {
213
+
s.service = service
214
+
}
215
+
}
216
+
217
+
// Specify the Duration in seconds for the expiry of this token
218
+
//
219
+
// The time of expiry is calculated as time.Now().Unix() + exp
220
+
func WithExp(exp int64) ServiceClientOpt {
221
+
return func(s *ServiceClientOpts) {
222
+
s.exp = time.Now().Unix() + exp
223
+
}
224
+
}
225
+
226
+
func WithLxm(lxm string) ServiceClientOpt {
227
+
return func(s *ServiceClientOpts) {
228
+
s.lxm = lxm
229
+
}
230
+
}
231
+
232
+
func WithDev(dev bool) ServiceClientOpt {
233
+
return func(s *ServiceClientOpts) {
234
+
s.dev = dev
235
+
}
236
+
}
237
+
238
+
func WithTimeout(timeout time.Duration) ServiceClientOpt {
239
+
return func(s *ServiceClientOpts) {
240
+
s.timeout = timeout
241
+
}
242
+
}
243
+
244
+
func (s *ServiceClientOpts) Audience() string {
245
+
return fmt.Sprintf("did:web:%s", s.service)
246
+
}
247
+
248
+
func (s *ServiceClientOpts) Host() string {
249
+
scheme := "https://"
250
+
if s.dev {
251
+
scheme = "http://"
252
+
}
253
+
254
+
return scheme + s.service
255
+
}
256
+
257
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
258
+
opts := DefaultServiceClientOpts()
259
+
for _, o := range os {
260
+
o(&opts)
261
+
}
262
+
263
+
client, err := o.AuthorizedClient(r)
264
+
if err != nil {
265
+
return nil, err
266
+
}
267
+
268
+
// force expiry to atleast 60 seconds in the future
269
+
sixty := time.Now().Unix() + 60
270
+
if opts.exp < sixty {
271
+
opts.exp = sixty
272
+
}
273
+
274
+
resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm)
275
+
if err != nil {
276
+
return nil, err
277
+
}
278
+
279
+
return &xrpc.Client{
280
+
Auth: &xrpc.AuthInfo{
281
+
AccessJwt: resp.Token,
282
+
},
283
+
Host: opts.Host(),
284
+
Client: &http.Client{
285
+
Timeout: opts.timeout,
286
+
},
287
+
}, nil
288
+
}
+269
internal/server/oauth/store.go
+269
internal/server/oauth/store.go
···
1
+
// MIT License
2
+
//
3
+
// Copyright (c) 2025 Anirudh Oppiliappan, Akshay Oppiliappan and
4
+
// contributors.
5
+
//
6
+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7
+
// of this software and associated documentation files (the "Software"), to deal
8
+
// in the Software without restriction, including without limitation the rights
9
+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+
// copies of the Software, and to permit persons to whom the Software is
11
+
// furnished to do so, subject to the following conditions:
12
+
//
13
+
// The above copyright notice and this permission notice shall be included in all
14
+
// copies or substantial portions of the Software.
15
+
//
16
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+
// SOFTWARE.
23
+
24
+
package oauth
25
+
26
+
import (
27
+
"context"
28
+
"encoding/json"
29
+
"fmt"
30
+
"time"
31
+
32
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
33
+
"github.com/bluesky-social/indigo/atproto/syntax"
34
+
"github.com/redis/go-redis/v9"
35
+
)
36
+
37
+
type RedisStoreConfig struct {
38
+
RedisURL string
39
+
40
+
// The purpose of these limits is to avoid dead sessions hanging around in the db indefinitely.
41
+
// The durations here should be *at least as long as* the expected duration of the oauth session itself.
42
+
SessionExpiryDuration time.Duration // duration since session creation (max TTL)
43
+
SessionInactivityDuration time.Duration // duration since last session update
44
+
AuthRequestExpiryDuration time.Duration // duration since auth request creation
45
+
}
46
+
47
+
// redis-backed implementation of ClientAuthStore.
48
+
type RedisStore struct {
49
+
client *redis.Client
50
+
cfg *RedisStoreConfig
51
+
}
52
+
53
+
var _ oauth.ClientAuthStore = &RedisStore{}
54
+
55
+
type sessionMetadata struct {
56
+
CreatedAt time.Time `json:"created_at"`
57
+
UpdatedAt time.Time `json:"updated_at"`
58
+
}
59
+
60
+
func NewRedisStore(cfg *RedisStoreConfig) (*RedisStore, error) {
61
+
if cfg == nil {
62
+
return nil, fmt.Errorf("missing cfg")
63
+
}
64
+
if cfg.RedisURL == "" {
65
+
return nil, fmt.Errorf("missing RedisURL")
66
+
}
67
+
if cfg.SessionExpiryDuration == 0 {
68
+
return nil, fmt.Errorf("missing SessionExpiryDuration")
69
+
}
70
+
if cfg.SessionInactivityDuration == 0 {
71
+
return nil, fmt.Errorf("missing SessionInactivityDuration")
72
+
}
73
+
if cfg.AuthRequestExpiryDuration == 0 {
74
+
return nil, fmt.Errorf("missing AuthRequestExpiryDuration")
75
+
}
76
+
77
+
opts, err := redis.ParseURL(cfg.RedisURL)
78
+
if err != nil {
79
+
return nil, fmt.Errorf("failed to parse redis URL: %w", err)
80
+
}
81
+
82
+
client := redis.NewClient(opts)
83
+
84
+
// test the connection
85
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
86
+
defer cancel()
87
+
88
+
if err := client.Ping(ctx).Err(); err != nil {
89
+
return nil, fmt.Errorf("failed to connect to redis: %w", err)
90
+
}
91
+
92
+
return &RedisStore{
93
+
client: client,
94
+
cfg: cfg,
95
+
}, nil
96
+
}
97
+
98
+
func (r *RedisStore) Close() error {
99
+
return r.client.Close()
100
+
}
101
+
102
+
func sessionKey(did syntax.DID, sessionID string) string {
103
+
return fmt.Sprintf("oauth:session:%s:%s", did, sessionID)
104
+
}
105
+
106
+
func sessionMetadataKey(did syntax.DID, sessionID string) string {
107
+
return fmt.Sprintf("oauth:session_meta:%s:%s", did, sessionID)
108
+
}
109
+
110
+
func authRequestKey(state string) string {
111
+
return fmt.Sprintf("oauth:auth_request:%s", state)
112
+
}
113
+
114
+
func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
115
+
key := sessionKey(did, sessionID)
116
+
metaKey := sessionMetadataKey(did, sessionID)
117
+
118
+
// Check metadata for inactivity expiry
119
+
metaData, err := r.client.Get(ctx, metaKey).Bytes()
120
+
if err == redis.Nil {
121
+
return nil, fmt.Errorf("session not found: %s", did)
122
+
}
123
+
if err != nil {
124
+
return nil, fmt.Errorf("failed to get session metadata: %w", err)
125
+
}
126
+
127
+
var meta sessionMetadata
128
+
if err := json.Unmarshal(metaData, &meta); err != nil {
129
+
return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err)
130
+
}
131
+
132
+
// Check if session has been inactive for too long
133
+
inactiveThreshold := time.Now().Add(-r.cfg.SessionInactivityDuration)
134
+
if meta.UpdatedAt.Before(inactiveThreshold) {
135
+
// Session is inactive, delete it
136
+
r.client.Del(ctx, key, metaKey)
137
+
return nil, fmt.Errorf("session expired due to inactivity: %s", did)
138
+
}
139
+
140
+
// Get the actual session data
141
+
data, err := r.client.Get(ctx, key).Bytes()
142
+
if err == redis.Nil {
143
+
return nil, fmt.Errorf("session not found: %s", did)
144
+
}
145
+
if err != nil {
146
+
return nil, fmt.Errorf("failed to get session: %w", err)
147
+
}
148
+
149
+
var sess oauth.ClientSessionData
150
+
if err := json.Unmarshal(data, &sess); err != nil {
151
+
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
152
+
}
153
+
154
+
return &sess, nil
155
+
}
156
+
157
+
func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
158
+
key := sessionKey(sess.AccountDID, sess.SessionID)
159
+
metaKey := sessionMetadataKey(sess.AccountDID, sess.SessionID)
160
+
161
+
data, err := json.Marshal(sess)
162
+
if err != nil {
163
+
return fmt.Errorf("failed to marshal session: %w", err)
164
+
}
165
+
166
+
// Check if session already exists to preserve CreatedAt
167
+
var meta sessionMetadata
168
+
existingMetaData, err := r.client.Get(ctx, metaKey).Bytes()
169
+
if err == redis.Nil {
170
+
// New session
171
+
meta = sessionMetadata{
172
+
CreatedAt: time.Now(),
173
+
UpdatedAt: time.Now(),
174
+
}
175
+
} else if err != nil {
176
+
return fmt.Errorf("failed to check existing session metadata: %w", err)
177
+
} else {
178
+
// Existing session - preserve CreatedAt, update UpdatedAt
179
+
if err := json.Unmarshal(existingMetaData, &meta); err != nil {
180
+
return fmt.Errorf("failed to unmarshal existing session metadata: %w", err)
181
+
}
182
+
meta.UpdatedAt = time.Now()
183
+
}
184
+
185
+
// Calculate remaining TTL based on creation time
186
+
remainingTTL := r.cfg.SessionExpiryDuration - time.Since(meta.CreatedAt)
187
+
if remainingTTL <= 0 {
188
+
return fmt.Errorf("session has expired")
189
+
}
190
+
191
+
// Use the shorter of: remaining TTL or inactivity duration
192
+
ttl := min(r.cfg.SessionInactivityDuration, remainingTTL)
193
+
194
+
// Save session data
195
+
if err := r.client.Set(ctx, key, data, ttl).Err(); err != nil {
196
+
return fmt.Errorf("failed to save session: %w", err)
197
+
}
198
+
199
+
// Save metadata
200
+
metaData, err := json.Marshal(meta)
201
+
if err != nil {
202
+
return fmt.Errorf("failed to marshal session metadata: %w", err)
203
+
}
204
+
if err := r.client.Set(ctx, metaKey, metaData, ttl).Err(); err != nil {
205
+
return fmt.Errorf("failed to save session metadata: %w", err)
206
+
}
207
+
208
+
return nil
209
+
}
210
+
211
+
func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
212
+
key := sessionKey(did, sessionID)
213
+
metaKey := sessionMetadataKey(did, sessionID)
214
+
215
+
if err := r.client.Del(ctx, key, metaKey).Err(); err != nil {
216
+
return fmt.Errorf("failed to delete session: %w", err)
217
+
}
218
+
return nil
219
+
}
220
+
221
+
func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
222
+
key := authRequestKey(state)
223
+
data, err := r.client.Get(ctx, key).Bytes()
224
+
if err == redis.Nil {
225
+
return nil, fmt.Errorf("request info not found: %s", state)
226
+
}
227
+
if err != nil {
228
+
return nil, fmt.Errorf("failed to get auth request: %w", err)
229
+
}
230
+
231
+
var req oauth.AuthRequestData
232
+
if err := json.Unmarshal(data, &req); err != nil {
233
+
return nil, fmt.Errorf("failed to unmarshal auth request: %w", err)
234
+
}
235
+
236
+
return &req, nil
237
+
}
238
+
239
+
func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
240
+
key := authRequestKey(info.State)
241
+
242
+
// check if already exists (to match MemStore behavior)
243
+
exists, err := r.client.Exists(ctx, key).Result()
244
+
if err != nil {
245
+
return fmt.Errorf("failed to check auth request existence: %w", err)
246
+
}
247
+
if exists > 0 {
248
+
return fmt.Errorf("auth request already saved for state %s", info.State)
249
+
}
250
+
251
+
data, err := json.Marshal(info)
252
+
if err != nil {
253
+
return fmt.Errorf("failed to marshal auth request: %w", err)
254
+
}
255
+
256
+
if err := r.client.Set(ctx, key, data, r.cfg.AuthRequestExpiryDuration).Err(); err != nil {
257
+
return fmt.Errorf("failed to save auth request: %w", err)
258
+
}
259
+
260
+
return nil
261
+
}
262
+
263
+
func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
264
+
key := authRequestKey(state)
265
+
if err := r.client.Del(ctx, key).Err(); err != nil {
266
+
return fmt.Errorf("failed to delete auth request: %w", err)
267
+
}
268
+
return nil
269
+
}
+2
internal/server/router.go
+2
internal/server/router.go
+23
-2
internal/server/server.go
+23
-2
internal/server/server.go
···
2
2
3
3
import (
4
4
"context"
5
+
"fmt"
5
6
7
+
"shelf.app/internal/atproto"
8
+
"shelf.app/internal/cache"
9
+
"shelf.app/internal/cache/session"
6
10
"shelf.app/internal/config"
11
+
"shelf.app/internal/server/oauth"
7
12
)
8
13
9
14
type Server struct {
10
-
config *config.Config
15
+
oauth *oauth.OAuth
16
+
config *config.Config
17
+
idResolver *atproto.Resolver
18
+
session *session.SessionStore
11
19
}
12
20
13
21
func Make(ctx context.Context, config *config.Config) (*Server, error) {
22
+
idResolver := atproto.DefaultResolver()
23
+
24
+
oauth, err := oauth.New(config, idResolver)
25
+
if err != nil {
26
+
return nil, fmt.Errorf("failed to start oauth handler: %w", err)
27
+
}
28
+
29
+
cache := cache.New(config.Redis.Addr)
30
+
session := session.New(cache)
31
+
14
32
return &Server{
15
-
config: config,
33
+
oauth: oauth,
34
+
config: config,
35
+
idResolver: idResolver,
36
+
session: session,
16
37
}, nil
17
38
}
18
39
History
1 round
1 comment
brookjeynes.dev
submitted
#0
expand 1 comment
pull request successfully merged
I have tested the OAuth integration, and it is functioning correctly. Everything seems to be working!