this repo has no description
at main 9.6 kB view raw
1package main 2 3import ( 4 _ "embed" 5 "encoding/json" 6 "fmt" 7 "html/template" 8 "log/slog" 9 "net/http" 10 "os" 11 12 _ "github.com/joho/godotenv/autoload" 13 14 "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 "github.com/bluesky-social/indigo/atproto/identity" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 18 "github.com/gorilla/sessions" 19 "github.com/urfave/cli/v2" 20) 21 22func main() { 23 app := cli.App{ 24 Name: "oauth-web-demo", 25 Usage: "atproto OAuth web server demo", 26 Action: runServer, 27 Flags: []cli.Flag{ 28 &cli.StringFlag{ 29 Name: "session-secret", 30 Usage: "random string/token used for session cookie security", 31 Required: true, 32 EnvVars: []string{"SESSION_SECRET"}, 33 }, 34 &cli.StringFlag{ 35 Name: "hostname", 36 Usage: "public host name for this client (if not localhost dev mode)", 37 EnvVars: []string{"CLIENT_HOSTNAME"}, 38 }, 39 }, 40 } 41 h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}) 42 slog.SetDefault(slog.New(h)) 43 app.RunAndExitOnError() 44} 45 46type Server struct { 47 CookieStore *sessions.CookieStore 48 Dir identity.Directory 49 OAuth *oauth.ClientApp 50} 51 52//go:embed "base.html" 53var tmplBaseText string 54 55//go:embed "home.html" 56var tmplHomeText string 57var tmplHome = template.Must(template.Must(template.New("home.html").Parse(tmplBaseText)).Parse(tmplHomeText)) 58 59//go:embed "login.html" 60var tmplLoginText string 61var tmplLogin = template.Must(template.Must(template.New("login.html").Parse(tmplBaseText)).Parse(tmplLoginText)) 62 63//go:embed "intents.html" 64var tmplIntentsText string 65var tmplIntents = template.Must(template.Must(template.New("intents.html").Parse(tmplBaseText)).Parse(tmplIntentsText)) 66 67type WebInfo struct { 68 DID string 69 //Handle string 70 Declaration *Declaration 71} 72 73func runServer(cctx *cli.Context) error { 74 75 scopes := []string{"atproto", "transition:generic"} 76 bind := ":8080" 77 78 // TODO: localhost dev mode if hostname is empty 79 var config oauth.ClientConfig 80 hostname := cctx.String("hostname") 81 if hostname == "" { 82 config = oauth.NewLocalhostConfig( 83 fmt.Sprintf("http://127.0.0.1%s/oauth/callback", bind), 84 scopes, 85 ) 86 slog.Info("configuring localhost OAuth client", "CallbackURL", config.CallbackURL) 87 } else { 88 config = oauth.NewPublicConfig( 89 fmt.Sprintf("https://%s/oauth/client-metadata.json", hostname), 90 fmt.Sprintf("https://%s/oauth/callback", hostname), 91 scopes, 92 ) 93 } 94 95 oauthClient := oauth.NewClientApp(&config, oauth.NewMemStore()) 96 97 srv := Server{ 98 CookieStore: sessions.NewCookieStore([]byte(cctx.String("session-secret"))), 99 Dir: identity.DefaultDirectory(), 100 OAuth: oauthClient, 101 } 102 103 http.HandleFunc("GET /", srv.Homepage) 104 http.HandleFunc("GET /oauth/client-metadata.json", srv.ClientMetadata) 105 http.HandleFunc("GET /oauth/jwks.json", srv.JWKS) 106 http.HandleFunc("GET /oauth/login", srv.OAuthLogin) 107 http.HandleFunc("POST /oauth/login", srv.OAuthLogin) 108 http.HandleFunc("GET /oauth/callback", srv.OAuthCallback) 109 http.HandleFunc("GET /oauth/logout", srv.OAuthLogout) 110 http.HandleFunc("GET /intents", srv.UpdateIntents) 111 http.HandleFunc("POST /intents", srv.UpdateIntents) 112 113 slog.Info("starting http server", "bind", bind) 114 if err := http.ListenAndServe(bind, nil); err != nil { 115 slog.Error("http shutdown", "err", err) 116 } 117 return nil 118} 119 120func (s *Server) currentSessionDID(r *http.Request) (*syntax.DID, string) { 121 sess, _ := s.CookieStore.Get(r, "oauth-demo") 122 accountDID, ok := sess.Values["account_did"].(string) 123 if !ok || accountDID == "" { 124 return nil, "" 125 } 126 did, err := syntax.ParseDID(accountDID) 127 if err != nil { 128 return nil, "" 129 } 130 sessionID, ok := sess.Values["session_id"].(string) 131 if !ok || sessionID == "" { 132 return nil, "" 133 } 134 135 return &did, sessionID 136} 137 138func strPtr(raw string) *string { 139 return &raw 140} 141 142func (s *Server) ClientMetadata(w http.ResponseWriter, r *http.Request) { 143 slog.Info("client metadata request", "url", r.URL, "host", r.Host) 144 145 meta := s.OAuth.Config.ClientMetadata() 146 meta.ClientName = strPtr("AI-PREF / Bluesky Demo App") 147 meta.ClientURI = strPtr(fmt.Sprintf("https://%s", r.Host)) 148 149 // internal consistency check 150 if err := meta.Validate(s.OAuth.Config.ClientID); err != nil { 151 slog.Error("validating client metadata", "err", err) 152 http.Error(w, err.Error(), http.StatusInternalServerError) 153 return 154 } 155 156 w.Header().Set("Content-Type", "application/json") 157 if err := json.NewEncoder(w).Encode(meta); err != nil { 158 http.Error(w, err.Error(), http.StatusInternalServerError) 159 return 160 } 161} 162 163func (s *Server) JWKS(w http.ResponseWriter, r *http.Request) { 164 w.Header().Set("Content-Type", "application/json") 165 body := s.OAuth.Config.PublicJWKS() 166 if err := json.NewEncoder(w).Encode(body); err != nil { 167 http.Error(w, err.Error(), http.StatusInternalServerError) 168 return 169 } 170} 171 172func (s *Server) Homepage(w http.ResponseWriter, r *http.Request) { 173 did, _ := s.currentSessionDID(r) 174 if did != nil { 175 tmplHome.Execute(w, WebInfo{DID: did.String()}) 176 return 177 } 178 tmplHome.Execute(w, nil) 179} 180 181func (s *Server) OAuthLogin(w http.ResponseWriter, r *http.Request) { 182 ctx := r.Context() 183 184 if r.Method != "POST" { 185 tmplLogin.Execute(w, nil) 186 return 187 } 188 189 if err := r.ParseForm(); err != nil { 190 http.Error(w, fmt.Errorf("parsing form data: %w", err).Error(), http.StatusBadRequest) 191 return 192 } 193 194 username := r.PostFormValue("username") 195 196 slog.Info("OAuthLogin", "client_id", s.OAuth.Config.ClientID, "callback_url", s.OAuth.Config.CallbackURL) 197 198 redirectURL, err := s.OAuth.StartAuthFlow(ctx, username) 199 if err != nil { 200 http.Error(w, fmt.Errorf("OAuth login failed: %w", err).Error(), http.StatusBadRequest) 201 return 202 } 203 204 http.Redirect(w, r, redirectURL, http.StatusFound) 205 return 206} 207 208func (s *Server) OAuthCallback(w http.ResponseWriter, r *http.Request) { 209 ctx := r.Context() 210 211 params := r.URL.Query() 212 slog.Info("received callback", "params", params) 213 214 sessData, err := s.OAuth.ProcessCallback(ctx, r.URL.Query()) 215 if err != nil { 216 http.Error(w, fmt.Errorf("processing OAuth callback: %w", err).Error(), http.StatusBadRequest) 217 return 218 } 219 220 // create signed cookie session, indicating account DID 221 sess, _ := s.CookieStore.Get(r, "oauth-demo") 222 sess.Values["account_did"] = sessData.AccountDID.String() 223 sess.Values["session_id"] = sessData.SessionID 224 if err := sess.Save(r, w); err != nil { 225 http.Error(w, err.Error(), http.StatusInternalServerError) 226 return 227 } 228 229 slog.Info("login successful", "did", sessData.AccountDID.String()) 230 http.Redirect(w, r, "/intents", http.StatusFound) 231} 232 233func (s *Server) OAuthLogout(w http.ResponseWriter, r *http.Request) { 234 235 // delete session from auth store 236 did, sessionID := s.currentSessionDID(r) 237 if did != nil { 238 if err := s.OAuth.Store.DeleteSession(r.Context(), *did, sessionID); err != nil { 239 slog.Error("failed to delete session", "did", did, "err", err) 240 } 241 } 242 243 // wipe all secure cookie session data 244 sess, _ := s.CookieStore.Get(r, "oauth-demo") 245 sess.Values = make(map[any]any) 246 err := sess.Save(r, w) 247 if err != nil { 248 http.Error(w, err.Error(), http.StatusInternalServerError) 249 return 250 } 251 slog.Info("logged out") 252 http.Redirect(w, r, "/", http.StatusFound) 253} 254 255func parseTriState(raw string) *bool { 256 switch raw { 257 case "allow": 258 v := true 259 return &v 260 case "disallow": 261 v := false 262 return &v 263 default: 264 return nil 265 } 266} 267 268func (s *Server) UpdateIntents(w http.ResponseWriter, r *http.Request) { 269 ctx := r.Context() 270 271 did, sessionID := s.currentSessionDID(r) 272 if did == nil { 273 // TODO: suppowed to set a WWW header; and could redirect? 274 http.Error(w, "not authenticated", http.StatusUnauthorized) 275 return 276 } 277 278 oauthSess, err := s.OAuth.ResumeSession(ctx, *did, sessionID) 279 if err != nil { 280 http.Error(w, "not authenticated", http.StatusUnauthorized) 281 return 282 } 283 c := oauthSess.APIClient() 284 info := WebInfo{ 285 DID: did.String(), 286 } 287 288 p := map[string]any{ 289 "repo": did.String(), 290 "collection": "org.user-intents.demo.declaration", 291 "rkey": "self", 292 } 293 resp := GetDeclarationResp{} 294 err = c.Get(ctx, "com.atproto.repo.getRecord", p, &resp) 295 if err != nil { 296 slog.Info("could not fetch existing declaration", "err", err) 297 } 298 299 if r.Method != "POST" { 300 info.Declaration = &resp.Value 301 tmplIntents.Execute(w, info) 302 return 303 } 304 305 if err := r.ParseForm(); err != nil { 306 http.Error(w, fmt.Errorf("parsing form data: %w", err).Error(), http.StatusBadRequest) 307 return 308 } 309 310 // TODO: have this not clobber current timestamps 311 now := syntax.DatetimeNow().String() 312 decl := Declaration{ 313 Type: "org.user-intents.demo.declaration", 314 UpdatedAt: now, 315 SyntheticContentGeneration: &DeclarationIntent{ 316 Allow: parseTriState(r.PostFormValue("syntheticContentGeneration")), 317 UpdatedAt: now, 318 }, 319 PublicAccessArchive: &DeclarationIntent{ 320 Allow: parseTriState(r.PostFormValue("publicAccessArchive")), 321 UpdatedAt: now, 322 }, 323 BulkDataset: &DeclarationIntent{ 324 Allow: parseTriState(r.PostFormValue("bulkDataset")), 325 UpdatedAt: now, 326 }, 327 ProtocolBridging: &DeclarationIntent{ 328 Allow: parseTriState(r.PostFormValue("protocolBridging")), 329 UpdatedAt: now, 330 }, 331 } 332 333 //nope := false 334 body := PutDeclarationBody{ 335 Repo: did.String(), 336 Collection: "org.user-intents.demo.declaration", 337 Rkey: "self", 338 Record: decl, 339 } 340 341 slog.Info("updating intents", "did", did, "declaration", decl) 342 if err := c.Post(ctx, "com.atproto.repo.putRecord", body, nil); err != nil { 343 slog.Info("failed to update intents record", "err", err) 344 http.Error(w, fmt.Errorf("update failed: %w", err).Error(), http.StatusBadRequest) 345 return 346 } 347 348 http.Redirect(w, r, "/", http.StatusFound) 349}