From d21728aea70a20a39e5dc6cd87e3584925161707 Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Mon, 2 Mar 2026 23:33:37 +1000 Subject: [PATCH] feat: add oauth redis store Signed-off-by: brookjeynes --- go.mod | 19 ++- go.sum | 39 +++++ internal/config/config.go | 23 +++ internal/server/oauth/store.go | 269 +++++++++++++++++++++++++++++++++ 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 internal/server/oauth/store.go diff --git a/go.mod b/go.mod index 7750eed..1b9d885 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,31 @@ module shelf.app go 1.25 require ( - github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e + github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e ) require ( github.com/a-h/templ v0.3.977 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/prometheus/client_golang v1.17.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/tdewolff/minify/v2 v2.24.8 // indirect github.com/tdewolff/parse/v2 v2.8.5 // indirect + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 89bbe1d..264a2d0 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,24 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e h1:dEM6bfzMfkRI39GLinuhQan47HzdrkqIzJkl/zRvz8s= github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e/go.mod h1:KIy0FgNQacp4uv2Z7xhNkV3qZiUSGuRky97s7Pa4v+o= +github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab h1:Cs35T2tAN3Q6mMH5mBaY09nmCNOn/GkZS1F7jfMxlR8= +github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= +github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= @@ -22,8 +32,14 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -37,6 +53,8 @@ github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= @@ -88,6 +106,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= @@ -113,6 +133,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= @@ -155,6 +185,10 @@ github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsr github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= @@ -217,10 +251,13 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -240,6 +277,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/internal/config/config.go b/internal/config/config.go index 3af9b05..d1b4417 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,8 @@ package config import ( "context" + "fmt" + "net/url" "github.com/sethvargo/go-envconfig" ) @@ -18,6 +20,26 @@ type OAuthConfig struct { ClientKid string `env:"CLIENT_KID"` } +type RedisConfig struct { + Addr string `env:"ADDR, default=localhost:6379"` + Password string `env:"PASS"` + DB int `env:"DB, default=0"` +} + +func (cfg RedisConfig) ToURL() string { + u := &url.URL{ + Scheme: "redis", + Host: cfg.Addr, + Path: fmt.Sprintf("/%d", cfg.DB), + } + + if cfg.Password != "" { + u.User = url.UserPassword("", cfg.Password) + } + + return u.String() +} + type JetstreamConfig struct { Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` } @@ -26,6 +48,7 @@ type Config struct { Core CoreConfig `env:",prefix=SHELF_"` Jetstream JetstreamConfig `env:",prefix=SHELF_JETSTREAM_"` OAuth OAuthConfig `env:",prefix=SHELF_OAUTH_"` + Redis RedisConfig `env:",prefix=SHELF_REDIS_"` } func LoadConfig(ctx context.Context) (*Config, error) { diff --git a/internal/server/oauth/store.go b/internal/server/oauth/store.go new file mode 100644 index 0000000..28cbf4e --- /dev/null +++ b/internal/server/oauth/store.go @@ -0,0 +1,269 @@ +// MIT License +// +// Copyright (c) 2025 Anirudh Oppiliappan, Akshay Oppiliappan and +// contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/redis/go-redis/v9" +) + +type RedisStoreConfig struct { + RedisURL string + + // The purpose of these limits is to avoid dead sessions hanging around in the db indefinitely. + // The durations here should be *at least as long as* the expected duration of the oauth session itself. + SessionExpiryDuration time.Duration // duration since session creation (max TTL) + SessionInactivityDuration time.Duration // duration since last session update + AuthRequestExpiryDuration time.Duration // duration since auth request creation +} + +// redis-backed implementation of ClientAuthStore. +type RedisStore struct { + client *redis.Client + cfg *RedisStoreConfig +} + +var _ oauth.ClientAuthStore = &RedisStore{} + +type sessionMetadata struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewRedisStore(cfg *RedisStoreConfig) (*RedisStore, error) { + if cfg == nil { + return nil, fmt.Errorf("missing cfg") + } + if cfg.RedisURL == "" { + return nil, fmt.Errorf("missing RedisURL") + } + if cfg.SessionExpiryDuration == 0 { + return nil, fmt.Errorf("missing SessionExpiryDuration") + } + if cfg.SessionInactivityDuration == 0 { + return nil, fmt.Errorf("missing SessionInactivityDuration") + } + if cfg.AuthRequestExpiryDuration == 0 { + return nil, fmt.Errorf("missing AuthRequestExpiryDuration") + } + + opts, err := redis.ParseURL(cfg.RedisURL) + if err != nil { + return nil, fmt.Errorf("failed to parse redis URL: %w", err) + } + + client := redis.NewClient(opts) + + // test the connection + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := client.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("failed to connect to redis: %w", err) + } + + return &RedisStore{ + client: client, + cfg: cfg, + }, nil +} + +func (r *RedisStore) Close() error { + return r.client.Close() +} + +func sessionKey(did syntax.DID, sessionID string) string { + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) +} + +func sessionMetadataKey(did syntax.DID, sessionID string) string { + return fmt.Sprintf("oauth:session_meta:%s:%s", did, sessionID) +} + +func authRequestKey(state string) string { + return fmt.Sprintf("oauth:auth_request:%s", state) +} + +func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { + key := sessionKey(did, sessionID) + metaKey := sessionMetadataKey(did, sessionID) + + // Check metadata for inactivity expiry + metaData, err := r.client.Get(ctx, metaKey).Bytes() + if err == redis.Nil { + return nil, fmt.Errorf("session not found: %s", did) + } + if err != nil { + return nil, fmt.Errorf("failed to get session metadata: %w", err) + } + + var meta sessionMetadata + if err := json.Unmarshal(metaData, &meta); err != nil { + return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err) + } + + // Check if session has been inactive for too long + inactiveThreshold := time.Now().Add(-r.cfg.SessionInactivityDuration) + if meta.UpdatedAt.Before(inactiveThreshold) { + // Session is inactive, delete it + r.client.Del(ctx, key, metaKey) + return nil, fmt.Errorf("session expired due to inactivity: %s", did) + } + + // Get the actual session data + data, err := r.client.Get(ctx, key).Bytes() + if err == redis.Nil { + return nil, fmt.Errorf("session not found: %s", did) + } + if err != nil { + return nil, fmt.Errorf("failed to get session: %w", err) + } + + var sess oauth.ClientSessionData + if err := json.Unmarshal(data, &sess); err != nil { + return nil, fmt.Errorf("failed to unmarshal session: %w", err) + } + + return &sess, nil +} + +func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { + key := sessionKey(sess.AccountDID, sess.SessionID) + metaKey := sessionMetadataKey(sess.AccountDID, sess.SessionID) + + data, err := json.Marshal(sess) + if err != nil { + return fmt.Errorf("failed to marshal session: %w", err) + } + + // Check if session already exists to preserve CreatedAt + var meta sessionMetadata + existingMetaData, err := r.client.Get(ctx, metaKey).Bytes() + if err == redis.Nil { + // New session + meta = sessionMetadata{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + } else if err != nil { + return fmt.Errorf("failed to check existing session metadata: %w", err) + } else { + // Existing session - preserve CreatedAt, update UpdatedAt + if err := json.Unmarshal(existingMetaData, &meta); err != nil { + return fmt.Errorf("failed to unmarshal existing session metadata: %w", err) + } + meta.UpdatedAt = time.Now() + } + + // Calculate remaining TTL based on creation time + remainingTTL := r.cfg.SessionExpiryDuration - time.Since(meta.CreatedAt) + if remainingTTL <= 0 { + return fmt.Errorf("session has expired") + } + + // Use the shorter of: remaining TTL or inactivity duration + ttl := min(r.cfg.SessionInactivityDuration, remainingTTL) + + // Save session data + if err := r.client.Set(ctx, key, data, ttl).Err(); err != nil { + return fmt.Errorf("failed to save session: %w", err) + } + + // Save metadata + metaData, err := json.Marshal(meta) + if err != nil { + return fmt.Errorf("failed to marshal session metadata: %w", err) + } + if err := r.client.Set(ctx, metaKey, metaData, ttl).Err(); err != nil { + return fmt.Errorf("failed to save session metadata: %w", err) + } + + return nil +} + +func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { + key := sessionKey(did, sessionID) + metaKey := sessionMetadataKey(did, sessionID) + + if err := r.client.Del(ctx, key, metaKey).Err(); err != nil { + return fmt.Errorf("failed to delete session: %w", err) + } + return nil +} + +func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { + key := authRequestKey(state) + data, err := r.client.Get(ctx, key).Bytes() + if err == redis.Nil { + return nil, fmt.Errorf("request info not found: %s", state) + } + if err != nil { + return nil, fmt.Errorf("failed to get auth request: %w", err) + } + + var req oauth.AuthRequestData + if err := json.Unmarshal(data, &req); err != nil { + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) + } + + return &req, nil +} + +func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { + key := authRequestKey(info.State) + + // check if already exists (to match MemStore behavior) + exists, err := r.client.Exists(ctx, key).Result() + if err != nil { + return fmt.Errorf("failed to check auth request existence: %w", err) + } + if exists > 0 { + return fmt.Errorf("auth request already saved for state %s", info.State) + } + + data, err := json.Marshal(info) + if err != nil { + return fmt.Errorf("failed to marshal auth request: %w", err) + } + + if err := r.client.Set(ctx, key, data, r.cfg.AuthRequestExpiryDuration).Err(); err != nil { + return fmt.Errorf("failed to save auth request: %w", err) + } + + return nil +} + +func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { + key := authRequestKey(state) + if err := r.client.Del(ctx, key).Err(); err != nil { + return fmt.Errorf("failed to delete auth request: %w", err) + } + return nil +} -- 2.51.2