this repo has no description
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}