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(oauth): add resolver

Signed-off-by: brookjeynes <me@brookjeynes.dev>

+377
+3
go.mod
··· 10 10 require ( 11 11 github.com/a-h/templ v0.3.977 // indirect 12 12 github.com/beorn7/perks v1.0.1 // indirect 13 + github.com/carlmjohnson/versioninfo v0.22.5 // indirect 13 14 github.com/cespare/xxhash/v2 v2.3.0 // indirect 14 15 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 15 16 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 16 17 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 17 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 18 21 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 19 22 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 20 23 github.com/prometheus/client_golang v1.17.0 // indirect
+6
go.sum
··· 8 8 github.com/bluesky-social/indigo v0.0.0-20251223190123-598fbf0e146e/go.mod h1:KIy0FgNQacp4uv2Z7xhNkV3qZiUSGuRky97s7Pa4v+o= 9 9 github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab h1:Cs35T2tAN3Q6mMH5mBaY09nmCNOn/GkZS1F7jfMxlR8= 10 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= 11 13 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 12 14 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 13 15 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= ··· 45 47 github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 46 48 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 47 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= 48 54 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 49 55 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 50 56 github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
+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 + }
+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 + )
+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/auth/oauth" 11 + atpclient "github.com/bluesky-social/indigo/atproto/client" 12 + atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 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 + SessStore *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 + SessStore: 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.SessStore.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.SessStore.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.SessStore.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.SessStore.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) (*atpclient.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 + }