Discover books, shows, and movies at your level. Track your progress by filling your Shelf with what you find, and share with other language learners. *No dusting required. shlf.space

feat: add oauth redis store #4

merged opened by brookjeynes.dev targeting master from push-onpzwzplvumy
Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:4mj54vc4ha3lh32ksxwunnbh/sh.tangled.repo.pull/3mg3hy2xxtr22
+1104 -4
Diff #0
+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
··· 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
··· 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
··· 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
··· 1 + package cache 2 + 3 + import "github.com/redis/go-redis/v9" 4 + 5 + type Cache struct { 6 + *redis.Client 7 + } 8 + 9 + func New(addr string) *Cache { 10 + rdb := redis.NewClient(&redis.Options{ 11 + Addr: addr, 12 + }) 13 + return &Cache{rdb} 14 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 16 16 router.Get("/login", s.Login) 17 17 router.Post("/login", s.Login) 18 18 19 + router.Mount("/", s.oauth.Router()) 20 + 19 21 return router 20 22 }
+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
sign up or login to add to the discussion
brookjeynes.dev submitted #0
3 commits
expand
feat: add oauth redis store
feat(oauth): add resolver
feat(oauth): hook up oauth to login process
expand 1 comment

I have tested the OAuth integration, and it is functioning correctly. Everything seems to be working!

pull request successfully merged