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): hook up oauth to login process

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

authored by brookjeynes.dev and committed by tangled.org 7ca73327 151503ca

+396 -21
+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
+1 -1
internal/atproto/resolver.go
··· 52 52 return nil, err 53 53 } 54 54 55 - return r.directory.Lookup(ctx, *id) 55 + return r.directory.Lookup(ctx, id) 56 56 } 57 57 58 58 func (r *Resolver) Directory() identity.Directory {
+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 + }
+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 + }
+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 + }
+17 -17
internal/server/oauth/oauth.go
··· 7 7 "time" 8 8 9 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" 10 12 "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 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 14 xrpc "github.com/bluesky-social/indigo/xrpc" 15 15 "github.com/gorilla/sessions" ··· 18 18 ) 19 19 20 20 type OAuth struct { 21 - ClientApp *oauth.ClientApp 22 - SessStore *sessions.CookieStore 23 - Config *config.Config 24 - JwksUri string 25 - IdResolver *idresolver.Resolver 21 + ClientApp *oauth.ClientApp 22 + SessionStore *sessions.CookieStore 23 + Config *config.Config 24 + JwksUri string 25 + IdResolver *idresolver.Resolver 26 26 } 27 27 28 28 func New(config *config.Config, res *idresolver.Resolver) (*OAuth, error) { ··· 74 74 } 75 75 76 76 return &OAuth{ 77 - ClientApp: clientApp, 78 - Config: config, 79 - SessStore: sessStore, 80 - JwksUri: jwksUri, 81 - IdResolver: res, 77 + ClientApp: clientApp, 78 + Config: config, 79 + SessionStore: sessStore, 80 + JwksUri: jwksUri, 81 + IdResolver: res, 82 82 }, nil 83 83 } 84 84 85 85 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 86 - userSession, err := o.SessStore.Get(r, SessionName) 86 + userSession, err := o.SessionStore.Get(r, SessionName) 87 87 if err != nil { 88 88 return err 89 89 } ··· 101 101 } 102 102 103 103 func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 104 - userSession, err := o.SessStore.Get(r, SessionName) 104 + userSession, err := o.SessionStore.Get(r, SessionName) 105 105 if err != nil { 106 106 return nil, fmt.Errorf("error getting user session: %w", err) 107 107 } ··· 126 126 } 127 127 128 128 func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 129 - userSession, err := o.SessStore.Get(r, SessionName) 129 + userSession, err := o.SessionStore.Get(r, SessionName) 130 130 if err != nil { 131 131 return fmt.Errorf("error getting user session: %w", err) 132 132 } ··· 150 150 151 151 // remove the cookie 152 152 userSession.Options.MaxAge = -1 153 - err2 := o.SessStore.Save(r, w, userSession) 153 + err2 := o.SessionStore.Save(r, w, userSession) 154 154 if err2 != nil { 155 155 err2 = fmt.Errorf("failed to save into session store: %w", err2) 156 156 } ··· 183 183 return "" 184 184 } 185 185 186 - func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 186 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atclient.APIClient, error) { 187 187 session, err := o.ResumeSession(r) 188 188 if err != nil { 189 189 return nil, fmt.Errorf("error getting session: %w", err)
+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