this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

OAuth client SDK (#1100)

Design goals:

- specific to atproto OAuth (not a general-purpose OAuth framework)
- implementation is correct and interoperable with atproto specification
- reasonably complete and flexible, though may make some opinionated
implementation decisions to avoid footguns
- compatible with client SDK (aka, implement `AuthMethod`)
- oriented towards server-side (eg, BFF and integrations)
- supports "just authn" use-cases

Progress:

- [x] basic confidential client demo web interface
- [x] public client mode
- [x] localhost dev client mode
- [x] refactor core types and method attachments (eg, a session-agnositc
OAuthClient struct with http.Client)
- [x] persist token callback (wired to ClientApp)
- [x] make PAR DPoP retries more specific (parse error response)
- [x] resolve XXX and TODO
- [x] multiple session support (and document this pattern)
- [x] document authn-only usecase
- [x] update `randomNonce`
- [x] add doc comments to major functions/types
- [x] more consistent DPoP capitalization in variables (?)
- [ ] fix DID/handle display in demo app
- [ ] ability to embed JWKs in client metadata directly (blocked on
needing `key_ops`?)
- [ ] proactive detection and update (persist) when DPoP nonce changes
- [ ] remember token deletion endpoint as part of session; and add
logout helper which calls it (if defined)
- [ ] mock tests (like service auth has)

authored by

bnewbold and committed by
GitHub
e303f097 6f0837c2

+2582
+26
atproto/auth/oauth/HACKING.md
··· 1 + 2 + ## Package Structure 3 + 4 + `oauth.ClientApp` 5 + - represents an overall application or service; helps establish and manage oauth.ClientSession 6 + - wraps and manages client metadata, client attestation secret (for confidential clients), request and session storage 7 + 8 + `oauth.ClientSession` 9 + - represents an established user session, wrapping DPoP key, tokens, and other metadata 10 + - implements client.AuthMethod, for use with ApiClient 11 + - automates token refresh; for confidential clients requires ref to client secret 12 + - triggers callback when session data are updated (nonce, tokens) 13 + 14 + `oauth.OAuthStore` 15 + - interface for persistent storage systems for auth request and session metadata, including secrets and DPoP private keys 16 + 17 + `oauth.Resolver` 18 + - currently always resolves direct from the network; may add flexible caching or interface abstraction in the future 19 + 20 + 21 + ## Implementation Details 22 + 23 + - starts DPoP at PAR (specification is flexible about this) 24 + - requires ES256 (P-256) for DPoP and client attestation private keys; though flexible interface types are used in the API 25 + - scopes are configured as part of client metadata, and the same for each session 26 +
+21
atproto/auth/oauth/cmd/oauth-web-demo/README.md
··· 1 + 2 + OAuth SDK Web App Example 3 + ========================= 4 + 5 + This is a minimal Go web app showing how to use the OAuth client SDK. 6 + 7 + To get started, generated a `.env` file with the following variables: 8 + 9 + - `SESSION_SECRET` (required) is a random string for secure cookies, you can generate one with `openssl rand -hex 16` 10 + - `CLIENT_HOSTNAME` (optional) is a public web hostname at which the running web app can be reached on the public web, with `https://`. It needs to actually be reachable by remote servers, not just your local web browser; you can use a service like `ngrok` if experimenting on a laptop. Or, if you leave this blank, the app will run as a "localhost dev app". 11 + - `CLIENT_SECRET_KEY` (optional) is used to run as a "confidential" client, with client attestation. You can generate a private key with the `goat` CLI tool (`goat key generate -t P-256`) 12 + 13 + And example file might look like: 14 + 15 + ``` 16 + SESSION_SECRET=49922828917dc6ac2f2fd2cca78735c3 17 + CLIENT_SECRET_KEY=z42twLj2gZeJSeRgZ4yPyEb6Yg6nawhU2W8y2ETDDFFyvwym 18 + CLIENT_HOSTNAME=a9a7c2e14c.ngrok-free.app 19 + ``` 20 + 21 + Then run the demo (`go run .`) and connect with a web browser.
+38
atproto/auth/oauth/cmd/oauth-web-demo/base.html
··· 1 + 2 + <!doctype html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="referrer" content="origin-when-cross-origin"> 7 + <meta name="viewport" content="width=device-width, initial-scale=1"> 8 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.purple.min.css"> 9 + <title>atproto OAuth demo (indigo)</title> 10 + </head> 11 + <body> 12 + <header> 13 + <hgroup> 14 + <h1>atproto OAuth demo (indigo)</h1> 15 + {{ if false }} 16 + <p>Hello <span style="font-family: monospace;">@{{ "handle" }}</span>!</p> 17 + {{ end }} 18 + </hgroup> 19 + <nav> 20 + <ul> 21 + {{ if false }} 22 + <li><a href="/bsky/post">Create Post</a> 23 + <li><a href="/oauth/refresh">Refresh Token</a> 24 + <li><a href="/oauth/logout">Logout</a> 25 + {{ else }} 26 + <li><a href="/oauth/login">Login</a> 27 + {{ end }} 28 + <li><a href="https://github.com/bluesky-social/indigo/tree/main/atproto/auth/oauth">Code</a> 29 + </ul> 30 + </nav> 31 + </header> 32 + <main> 33 + <section class="content"> 34 + {{ template "content" . }} 35 + </section> 36 + </main> 37 + </body> 38 + </html>
+3
atproto/auth/oauth/cmd/oauth-web-demo/home.html
··· 1 + {{ define "content" }} 2 + This is home! 3 + {{ end }}
+13
atproto/auth/oauth/cmd/oauth-web-demo/login.html
··· 1 + {{ define "content" }} 2 + <article> 3 + <h3>Login with atproto</h3> 4 + <form method="post"> 5 + <p>Provide your handle or DID to authorize an existing account with PDS. 6 + <br>You can also supply a PDS/entryway URL (eg, <code>https://pds.example.com</code>).</p> 7 + <fieldset role="group"> 8 + <input name="username" id="username" placeholder="handle.example.com" style="font-family: monospace,monospace;" required> 9 + <input type="submit" value="Login"> 10 + </fieldset> 11 + </form> 12 + </article> 13 + {{ end }}
+345
atproto/auth/oauth/cmd/oauth-web-demo/main.go
··· 1 + package main 2 + 3 + import ( 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/crypto" 16 + "github.com/bluesky-social/indigo/atproto/identity" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 18 + 19 + "github.com/gorilla/sessions" 20 + "github.com/urfave/cli/v2" 21 + ) 22 + 23 + func main() { 24 + app := cli.App{ 25 + Name: "oauth-web-demo", 26 + Usage: "atproto OAuth web server demo", 27 + Action: runServer, 28 + Flags: []cli.Flag{ 29 + &cli.StringFlag{ 30 + Name: "session-secret", 31 + Usage: "random string/token used for session cookie security", 32 + Required: true, 33 + EnvVars: []string{"SESSION_SECRET"}, 34 + }, 35 + &cli.StringFlag{ 36 + Name: "hostname", 37 + Usage: "public host name for this client (if not localhost dev mode)", 38 + EnvVars: []string{"CLIENT_HOSTNAME"}, 39 + }, 40 + &cli.StringFlag{ 41 + Name: "client-secret-key", 42 + Usage: "confidential client secret key. should be P-256 private key in multibase encoding", 43 + EnvVars: []string{"CLIENT_SECRET_KEY"}, 44 + }, 45 + &cli.StringFlag{ 46 + Name: "client-secret-key-id", 47 + Usage: "key id for client-secret-key", 48 + Value: "primary", 49 + EnvVars: []string{"CLIENT_SECRET_KEY_ID"}, 50 + }, 51 + }, 52 + } 53 + h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}) 54 + slog.SetDefault(slog.New(h)) 55 + app.RunAndExitOnError() 56 + } 57 + 58 + type Server struct { 59 + CookieStore *sessions.CookieStore 60 + Dir identity.Directory 61 + OAuth *oauth.ClientApp 62 + } 63 + 64 + //go:embed "base.html" 65 + var tmplBaseText string 66 + 67 + //go:embed "home.html" 68 + var tmplHomeText string 69 + var tmplHome = template.Must(template.Must(template.New("home.html").Parse(tmplBaseText)).Parse(tmplHomeText)) 70 + 71 + //go:embed "login.html" 72 + var tmplLoginText string 73 + var tmplLogin = template.Must(template.Must(template.New("login.html").Parse(tmplBaseText)).Parse(tmplLoginText)) 74 + 75 + //go:embed "post.html" 76 + var tmplPostText string 77 + var tmplPost = template.Must(template.Must(template.New("post.html").Parse(tmplBaseText)).Parse(tmplPostText)) 78 + 79 + func (s *Server) Homepage(w http.ResponseWriter, r *http.Request) { 80 + tmplHome.Execute(w, nil) 81 + } 82 + 83 + func runServer(cctx *cli.Context) error { 84 + 85 + scopes := []string{"atproto", "transition:generic"} 86 + bind := ":8080" 87 + 88 + var config oauth.ClientConfig 89 + hostname := cctx.String("hostname") 90 + if hostname == "" { 91 + config = oauth.NewLocalhostConfig( 92 + fmt.Sprintf("http://127.0.0.1%s/oauth/callback", bind), 93 + scopes, 94 + ) 95 + slog.Info("configuring localhost OAuth client", "CallbackURL", config.CallbackURL) 96 + } else { 97 + config = oauth.NewPublicConfig( 98 + fmt.Sprintf("https://%s/oauth/client-metadata.json", hostname), 99 + fmt.Sprintf("https://%s/oauth/callback", hostname), 100 + scopes, 101 + ) 102 + } 103 + 104 + // If a client secret key is provided (as a multibase string), turn this in to a confidential client 105 + if cctx.String("client-secret-key") != "" && hostname != "" { 106 + priv, err := crypto.ParsePrivateMultibase(cctx.String("client-secret-key")) 107 + if err != nil { 108 + return err 109 + } 110 + if err := config.SetClientSecret(priv, cctx.String("client-secret-key-id")); err != nil { 111 + return err 112 + } 113 + slog.Info("configuring confidential OAuth client") 114 + } 115 + 116 + oauthClient := oauth.NewClientApp(&config, oauth.NewMemStore()) 117 + 118 + srv := Server{ 119 + CookieStore: sessions.NewCookieStore([]byte(cctx.String("session-secret"))), 120 + Dir: identity.DefaultDirectory(), 121 + OAuth: oauthClient, 122 + } 123 + 124 + http.HandleFunc("GET /", srv.Homepage) 125 + http.HandleFunc("GET /oauth/client-metadata.json", srv.ClientMetadata) 126 + http.HandleFunc("GET /oauth/jwks.json", srv.JWKS) 127 + http.HandleFunc("GET /oauth/login", srv.OAuthLogin) 128 + http.HandleFunc("POST /oauth/login", srv.OAuthLogin) 129 + http.HandleFunc("GET /oauth/callback", srv.OAuthCallback) 130 + http.HandleFunc("GET /oauth/refresh", srv.OAuthRefresh) 131 + http.HandleFunc("GET /oauth/logout", srv.OAuthLogout) 132 + http.HandleFunc("GET /bsky/post", srv.Post) 133 + http.HandleFunc("POST /bsky/post", srv.Post) 134 + 135 + slog.Info("starting http server", "bind", bind) 136 + if err := http.ListenAndServe(bind, nil); err != nil { 137 + slog.Error("http shutdown", "err", err) 138 + } 139 + return nil 140 + } 141 + 142 + func (s *Server) currentSessionDID(r *http.Request) (*syntax.DID, string) { 143 + sess, _ := s.CookieStore.Get(r, "oauth-demo") 144 + accountDID, ok := sess.Values["account_did"].(string) 145 + if !ok || accountDID == "" { 146 + return nil, "" 147 + } 148 + did, err := syntax.ParseDID(accountDID) 149 + if err != nil { 150 + return nil, "" 151 + } 152 + sessionID, ok := sess.Values["session_id"].(string) 153 + if !ok || sessionID == "" { 154 + return nil, "" 155 + } 156 + 157 + return &did, sessionID 158 + } 159 + 160 + func strPtr(raw string) *string { 161 + return &raw 162 + } 163 + 164 + func (s *Server) ClientMetadata(w http.ResponseWriter, r *http.Request) { 165 + slog.Info("client metadata request", "url", r.URL, "host", r.Host) 166 + 167 + meta := s.OAuth.Config.ClientMetadata() 168 + if s.OAuth.Config.IsConfidential() { 169 + meta.JWKSURI = strPtr(fmt.Sprintf("https://%s/oauth/jwks.json", r.Host)) 170 + } 171 + meta.ClientName = strPtr("indigo atp-oauth-demo") 172 + meta.ClientURI = strPtr(fmt.Sprintf("https://%s", r.Host)) 173 + 174 + // internal consistency check 175 + if err := meta.Validate(s.OAuth.Config.ClientID); err != nil { 176 + slog.Error("validating client metadata", "err", err) 177 + http.Error(w, err.Error(), http.StatusInternalServerError) 178 + return 179 + } 180 + 181 + w.Header().Set("Content-Type", "application/json") 182 + if err := json.NewEncoder(w).Encode(meta); err != nil { 183 + http.Error(w, err.Error(), http.StatusInternalServerError) 184 + return 185 + } 186 + } 187 + 188 + func (s *Server) JWKS(w http.ResponseWriter, r *http.Request) { 189 + w.Header().Set("Content-Type", "application/json") 190 + body := s.OAuth.Config.PublicJWKS() 191 + if err := json.NewEncoder(w).Encode(body); err != nil { 192 + http.Error(w, err.Error(), http.StatusInternalServerError) 193 + return 194 + } 195 + } 196 + 197 + func (s *Server) OAuthLogin(w http.ResponseWriter, r *http.Request) { 198 + ctx := r.Context() 199 + 200 + if r.Method != "POST" { 201 + tmplLogin.Execute(w, nil) 202 + return 203 + } 204 + 205 + if err := r.ParseForm(); err != nil { 206 + http.Error(w, fmt.Errorf("parsing form data: %w", err).Error(), http.StatusBadRequest) 207 + return 208 + } 209 + 210 + username := r.PostFormValue("username") 211 + 212 + slog.Info("OAuthLogin", "client_id", s.OAuth.Config.ClientID, "callback_url", s.OAuth.Config.CallbackURL) 213 + 214 + redirectURL, err := s.OAuth.StartAuthFlow(ctx, username) 215 + if err != nil { 216 + http.Error(w, fmt.Errorf("OAuth login failed: %w", err).Error(), http.StatusBadRequest) 217 + return 218 + } 219 + 220 + http.Redirect(w, r, redirectURL, http.StatusFound) 221 + return 222 + } 223 + 224 + func (s *Server) OAuthCallback(w http.ResponseWriter, r *http.Request) { 225 + ctx := r.Context() 226 + 227 + params := r.URL.Query() 228 + slog.Info("received callback", "params", params) 229 + 230 + sessData, err := s.OAuth.ProcessCallback(ctx, r.URL.Query()) 231 + if err != nil { 232 + http.Error(w, fmt.Errorf("processing OAuth callback: %w", err).Error(), http.StatusBadRequest) 233 + return 234 + } 235 + 236 + // create signed cookie session, indicating account DID 237 + sess, _ := s.CookieStore.Get(r, "oauth-demo") 238 + sess.Values["account_did"] = sessData.AccountDID.String() 239 + sess.Values["session_id"] = sessData.SessionID 240 + if err := sess.Save(r, w); err != nil { 241 + http.Error(w, err.Error(), http.StatusInternalServerError) 242 + return 243 + } 244 + 245 + slog.Info("login successful", "did", sessData.AccountDID.String()) 246 + http.Redirect(w, r, "/bsky/post", http.StatusFound) 247 + } 248 + 249 + func (s *Server) OAuthRefresh(w http.ResponseWriter, r *http.Request) { 250 + ctx := r.Context() 251 + 252 + did, sessionID := s.currentSessionDID(r) 253 + if did == nil { 254 + // TODO: supposed to set a WWW header; and could redirect? 255 + http.Error(w, "not authenticated", http.StatusUnauthorized) 256 + return 257 + } 258 + 259 + oauthSess, err := s.OAuth.ResumeSession(ctx, *did, sessionID) 260 + if err != nil { 261 + http.Error(w, "not authenticated", http.StatusUnauthorized) 262 + return 263 + } 264 + 265 + _, err = oauthSess.RefreshTokens(ctx) 266 + if err != nil { 267 + http.Error(w, err.Error(), http.StatusBadRequest) 268 + return 269 + } 270 + s.OAuth.Store.SaveSession(ctx, *oauthSess.Data) 271 + slog.Info("refreshed tokens") 272 + http.Redirect(w, r, "/", http.StatusFound) 273 + } 274 + 275 + func (s *Server) OAuthLogout(w http.ResponseWriter, r *http.Request) { 276 + 277 + // delete session from auth store 278 + did, sessionID := s.currentSessionDID(r) 279 + if did != nil { 280 + if err := s.OAuth.Store.DeleteSession(r.Context(), *did, sessionID); err != nil { 281 + slog.Error("failed to delete session", "did", did, "err", err) 282 + } 283 + } 284 + 285 + // wipe all secure cookie session data 286 + sess, _ := s.CookieStore.Get(r, "oauth-demo") 287 + sess.Values = make(map[any]any) 288 + err := sess.Save(r, w) 289 + if err != nil { 290 + http.Error(w, err.Error(), http.StatusInternalServerError) 291 + return 292 + } 293 + 294 + slog.Info("logged out") 295 + http.Redirect(w, r, "/", http.StatusFound) 296 + } 297 + 298 + func (s *Server) Post(w http.ResponseWriter, r *http.Request) { 299 + ctx := r.Context() 300 + 301 + slog.Info("in post handler") 302 + 303 + if r.Method != "POST" { 304 + tmplPost.Execute(w, nil) 305 + return 306 + } 307 + 308 + did, sessionID := s.currentSessionDID(r) 309 + if did == nil { 310 + // TODO: supposed to set a WWW header; and could redirect? 311 + http.Error(w, "not authenticated", http.StatusUnauthorized) 312 + return 313 + } 314 + 315 + oauthSess, err := s.OAuth.ResumeSession(ctx, *did, sessionID) 316 + if err != nil { 317 + http.Error(w, "not authenticated", http.StatusUnauthorized) 318 + return 319 + } 320 + c := oauthSess.APIClient() 321 + 322 + if err := r.ParseForm(); err != nil { 323 + http.Error(w, fmt.Errorf("parsing form data: %w", err).Error(), http.StatusBadRequest) 324 + return 325 + } 326 + text := r.PostFormValue("post_text") 327 + 328 + body := map[string]any{ 329 + "repo": c.AccountDID.String(), 330 + "collection": "app.bsky.feed.post", 331 + "record": map[string]any{ 332 + "$type": "app.bsky.feed.post", 333 + "text": text, 334 + "createdAt": syntax.DatetimeNow(), 335 + }, 336 + } 337 + 338 + slog.Info("attempting post...", "text", text) 339 + if err := c.Post(ctx, "com.atproto.repo.createRecord", body, nil); err != nil { 340 + http.Error(w, fmt.Errorf("posting failed: %w", err).Error(), http.StatusBadRequest) 341 + return 342 + } 343 + 344 + http.Redirect(w, r, "/bsky/post", http.StatusFound) 345 + }
+6
atproto/auth/oauth/cmd/oauth-web-demo/post.html
··· 1 + {{ define "content" }} 2 + <form method="post"> 3 + <textarea name="post_text" placeholder="What's up?" id="post_text" required></textarea> 4 + <input type="submit" value="Poast!"> 5 + </form> 6 + {{ end }}
+145
atproto/auth/oauth/doc.go
··· 1 + /* 2 + OAuth implementation for atproto, currently focused on clients. 3 + 4 + Feature set includes: 5 + 6 + - client and server metadata resolution 7 + - PKCE: computing and verifying challenges 8 + - DPoP client implementation: JWT signing and nonces for requests to Auth Server and Resource Server 9 + - PAR client submission 10 + - both public and confidential clients, with support for signed client attestations in the later case 11 + 12 + Most OAuth client applications will use the high-level [ClientApp] and supporting interfaces to manage session logins, persistence, and token refreshes. Lower-level components are designed to be used in isolation if needed. 13 + 14 + This package does not contain supporting code for atproto permissions or permission sets. It treats scopes as simple strings. 15 + 16 + ## Quickstart 17 + 18 + Create a single [ClientApp] instance during service setup that will be used (concurrently) across all users and sessions: 19 + 20 + config := oauth.NewPublicConfig( 21 + "https://app.example.com/client-metadata.json", 22 + "https://app.example.com/oauth/callback", 23 + []string{"atproto", "transition:generic"}, 24 + ) 25 + 26 + // clients are "public" by default, but if they have secure access to a secret attestation key can be "confidential" 27 + if CLIENT_SECRET_KEY != "" { 28 + priv, err := crypto.ParsePrivateMultibase(CLIENT_SECRET_KEY) 29 + if err != nil { 30 + return err 31 + } 32 + if err := config.SetClientSecret(priv, "example1"); err != nil { 33 + return err 34 + } 35 + } 36 + 37 + oauthApp := oauth.NewClientApp(&config, oauth.NewMemStore()) 38 + 39 + For a real service, you would want to use a database or other peristant storage instead of [MemStore]. Otherwise all user sessions are dropped every time the process restarts. 40 + 41 + The client metadata document needs to be served at the URL indicated by the `client_id`. This can be done statically, or dynamically generated and served from the configuration: 42 + 43 + http.HandleFunc("GET /client-metadata.json", HandleClientMetadata) 44 + 45 + func HandleClientMetadata(w http.ResponseWriter, r *http.Request) { 46 + doc := oauthApp.Config.ClientMetadata() 47 + 48 + // if this is is a confidential client, need to set doc.JWKSURI, and implement a handler 49 + 50 + w.Header().Set("Content-Type", "application/json") 51 + if err := json.NewEncoder(w).Encode(doc); err != nil { 52 + http.Error(w, err.Error(), http.StatusInternalServerError) 53 + return 54 + } 55 + } 56 + 57 + The login auth flow starts with a user identifier, which could be an atproto handle, DID, or an auth server URL (eg, a PDS). The high-level [StartAuthFlow()] method will resolve the identifier, send an auth request (PAR) to the server, persist request metadata in the [OAuthStore], and return a redirect URL for the user to visit (usually the PDS): 58 + 59 + http.HandleFunc("GET /oauth/login", HandleLogin) 60 + 61 + func HandleLogin(w http.ResponseWriter, r *http.Request) { 62 + ctx := r.Context() 63 + 64 + // parse login identifier from the request 65 + identifier := "..." 66 + 67 + redirectURL, err := oauthApp.StartAuthFlow(ctx, identifier) 68 + if err != nil { 69 + http.Error(w, err.Error(), http.StatusInternalServerError) 70 + } 71 + http.Redirect(w, r, redirectURL, http.StatusFound) 72 + } 73 + 74 + The service then waits for a callback request on the configured endpoint. The [ProcessCallback()] method will load the earlier request metadata from the [OAuthStore], send an initial token request to the auth server, and validate that the session is consistent with the identifier from the beginning of the login flow. 75 + 76 + http.HandleFunc("GET /oauth/callback", HandleOAuthCallback) 77 + 78 + func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { 79 + ctx := r.Context() 80 + 81 + sessData, err := oauthApp.ProcessCallback(ctx, r.URL.Query()) 82 + if err != nil { 83 + http.Error(w, err.Error(), http.StatusInternalServerError) 84 + } 85 + 86 + // web services might record the DID and session ID in a secure session cookie 87 + _ = sessData.AccountDID 88 + _ = sessData.SessionID 89 + 90 + // the returned scopes might not include all of those requested 91 + _ = sessData.Scopes 92 + 93 + http.Redirect(w, r, "/app", http.StatusFound) 94 + } 95 + 96 + Sessions can be resumed and used to make authenticated API calls to the user's host: 97 + 98 + // web services might use a secure session cookie to determine user's DID for a request 99 + did := syntax.DID("did:plc:abc123") 100 + sessionID := "xyz" 101 + 102 + sess, err := oauthApp.ResumeSession(ctx, did, sessionID) 103 + if err != nil { 104 + return err 105 + } 106 + 107 + c := sess.APIClient() 108 + 109 + body := map[string]any{ 110 + "repo": *c.AccountDID, 111 + "collection": "app.bsky.feed.post", 112 + "record": map[string]any{ 113 + "$type": "app.bsky.feed.post", 114 + "text": "Hello World via OAuth!", 115 + "createdAt": syntax.DatetimeNow(), 116 + }, 117 + } 118 + 119 + if err := c.Post(ctx, "com.atproto.repo.createRecord", body, nil); err != nil { 120 + return err 121 + } 122 + 123 + The [ClientSession] will handle nonce updates and token refreshes, and persist the results in the [OAuthStore]. 124 + 125 + To log out a user, delete their session from the [OAuthStore]: 126 + 127 + if err := oauthApp.Store.DeleteSession(r.Context(), did, sessionID); err != nil { 128 + return err 129 + } 130 + 131 + ## Authorization-only Situations 132 + 133 + Some applications might only use atproto OAuth for authorization (authn). For example, "Login with Atmospehre", where the application does not need to access additional account metadata (such as account email), or access any restricted account resources (eg, write to atproto repository). 134 + 135 + In this scenario, the client app still needs to do an initial token request, to confirm the account identifier. But the returned session tokens will never be used, and do not need to be persisted. 136 + 137 + In these scenarios, applications could use an implementation of [OAuthStore] which does not actually persist the session data when [OAuthStore.SaveSession] is called. Or, the application could immediately call [OAuthStore.DeleteSession] after [ClientApp.ProcessCallback] returns. 138 + 139 + ## Multiple Sessions Per Account 140 + 141 + In the traditional web app backend scenario, a single account (DID) might have multiple active sessions. For example, a user might log in from a browser on their laptop and on a mobile device at the same time. The user must go through the entire flow on each device (or browser) to authenticate the user. To prevent a new session from "clobbering" existing sessions (including tokens), this package supports multiple concurrent sessions per account, distinguished by a session ID. The random `state` token from the auth flow is re-used by default. 142 + 143 + In other scenarious, multiple sessions are not needed or desirable. For example, an integration backend, or tool with very short session lifetimes. In these scenarios, implementations of the [OAuthStore] interface could ignore the session ID. Or the [ClientApp] could be configured with an ephemeral [OAuthStore] (to support auth flows), and managed the session data returned by [ClientApp.ProcessCallback] using separate session storage logic. 144 + */ 145 + package oauth
+87
atproto/auth/oauth/jwt_signing.go
··· 1 + package oauth 2 + 3 + import ( 4 + "crypto" 5 + "fmt" 6 + 7 + atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 8 + "github.com/golang-jwt/jwt/v5" 9 + ) 10 + 11 + // NOTE: this file is copied from indigo:atproto/auth/jwt_signing.go, with the K-256 (ES256) support removed 12 + 13 + var ( 14 + signingMethodES256 *signingMethodAtproto 15 + supportedAlgs []string 16 + ) 17 + 18 + // Implementation of jwt.SigningMethod for the `atproto/crypto` types. 19 + type signingMethodAtproto struct { 20 + alg string 21 + hash crypto.Hash 22 + toOutSig toOutSig 23 + sigLen int 24 + } 25 + 26 + type toOutSig func(sig []byte) []byte 27 + 28 + func init() { 29 + // tells JWT library to serialize 'aud' as regular string, not array of strings (when signing) 30 + jwt.MarshalSingleStringAsArray = false 31 + 32 + signingMethodES256 = &signingMethodAtproto{ 33 + alg: "ES256", 34 + hash: crypto.SHA256, 35 + toOutSig: toES256, 36 + sigLen: 64, 37 + } 38 + jwt.RegisterSigningMethod(signingMethodES256.Alg(), func() jwt.SigningMethod { 39 + return signingMethodES256 40 + }) 41 + supportedAlgs = []string{signingMethodES256.Alg()} 42 + } 43 + 44 + func (sm *signingMethodAtproto) Verify(signingString string, sig []byte, key interface{}) error { 45 + pub, ok := key.(atcrypto.PublicKey) 46 + if !ok { 47 + return jwt.ErrInvalidKeyType 48 + } 49 + 50 + if !sm.hash.Available() { 51 + return jwt.ErrHashUnavailable 52 + } 53 + 54 + if len(sig) != sm.sigLen { 55 + return jwt.ErrTokenSignatureInvalid 56 + } 57 + 58 + // NOTE: important to use using "lenient" variant here. atproto cryptography is strict about details like low-S elliptic curve signatures, but OAuth cryptography is not, and we want to be interoperable with general purpose OAuth implementations 59 + return pub.HashAndVerifyLenient([]byte(signingString), sig) 60 + } 61 + 62 + func (sm *signingMethodAtproto) Sign(signingString string, key interface{}) ([]byte, error) { 63 + priv, ok := key.(atcrypto.PrivateKey) 64 + if !ok { 65 + return nil, jwt.ErrInvalidKeyType 66 + } 67 + 68 + return priv.HashAndSign([]byte(signingString)) 69 + } 70 + 71 + func (sm *signingMethodAtproto) Alg() string { 72 + return sm.alg 73 + } 74 + 75 + func toES256(sig []byte) []byte { 76 + return sig[:64] 77 + } 78 + 79 + func keySigningMethod(key atcrypto.PrivateKey) (jwt.SigningMethod, error) { 80 + switch key.(type) { 81 + case *atcrypto.PrivateKeyP256: 82 + return signingMethodES256, nil 83 + case *atcrypto.PrivateKeyK256: 84 + return nil, fmt.Errorf("only P-256 (ES256) private keys supported for atproto OAuth") 85 + } 86 + return nil, fmt.Errorf("unknown key type: %T", key) 87 + }
+87
atproto/auth/oauth/memstore.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "sync" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + // Simple in-memory implementation of [ClientAuthStore], for use in development and demos. 12 + // 13 + // This is not appropriate even casual real-world use: all users will be logged-out every time the process is restarted. 14 + type MemStore struct { 15 + requests map[string]AuthRequestData 16 + sessions map[string]ClientSessionData 17 + 18 + lk sync.Mutex 19 + } 20 + 21 + var _ ClientAuthStore = &MemStore{} 22 + 23 + func NewMemStore() *MemStore { 24 + return &MemStore{ 25 + requests: make(map[string]AuthRequestData), 26 + sessions: make(map[string]ClientSessionData), 27 + } 28 + } 29 + 30 + func memKey(did syntax.DID, sessionID string) string { 31 + return fmt.Sprintf("%s/%s", did, sessionID) 32 + } 33 + 34 + func (m *MemStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*ClientSessionData, error) { 35 + m.lk.Lock() 36 + defer m.lk.Unlock() 37 + 38 + sess, ok := m.sessions[memKey(did, sessionID)] 39 + if !ok { 40 + return nil, fmt.Errorf("session not found: %s", did) 41 + } 42 + return &sess, nil 43 + } 44 + 45 + func (m *MemStore) SaveSession(ctx context.Context, sess ClientSessionData) error { 46 + m.lk.Lock() 47 + defer m.lk.Unlock() 48 + 49 + m.sessions[memKey(sess.AccountDID, sess.SessionID)] = sess 50 + return nil 51 + } 52 + 53 + func (m *MemStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 54 + m.lk.Lock() 55 + defer m.lk.Unlock() 56 + 57 + delete(m.sessions, memKey(did, sessionID)) 58 + return nil 59 + } 60 + 61 + func (m *MemStore) GetAuthRequestInfo(ctx context.Context, state string) (*AuthRequestData, error) { 62 + m.lk.Lock() 63 + defer m.lk.Unlock() 64 + 65 + req, ok := m.requests[state] 66 + if !ok { 67 + return nil, fmt.Errorf("request info not found: %s", state) 68 + } 69 + // TODO: delete? should only ever fetch once 70 + return &req, nil 71 + } 72 + 73 + func (m *MemStore) SaveAuthRequestInfo(ctx context.Context, info AuthRequestData) error { 74 + m.lk.Lock() 75 + defer m.lk.Unlock() 76 + 77 + m.requests[info.State] = info 78 + return nil 79 + } 80 + 81 + func (m *MemStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 82 + m.lk.Lock() 83 + defer m.lk.Unlock() 84 + 85 + delete(m.requests, state) 86 + return nil 87 + }
+659
atproto/auth/oauth/oauth.go
··· 1 + package oauth 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + "net/url" 11 + "strings" 12 + "time" 13 + 14 + "github.com/bluesky-social/indigo/atproto/crypto" 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + 18 + "github.com/golang-jwt/jwt/v5" 19 + "github.com/google/go-querystring/query" 20 + ) 21 + 22 + var jwtExpirationDuration = 30 * time.Second 23 + 24 + // Service-level client. Used to establish and refrsh OAuth sessions, but is not itself account or session specific, and can not be used directly to make API calls on behalf of a user. 25 + type ClientApp struct { 26 + Client *http.Client 27 + Resolver *Resolver 28 + Dir identity.Directory 29 + Config *ClientConfig 30 + Store ClientAuthStore 31 + } 32 + 33 + // App-level client configuration data. 34 + // 35 + // Not to be confused with the [ClientMetadata] struct type, which represents a full client metadata JSON document. 36 + type ClientConfig struct { 37 + // Full client identifier, which should be an HTTP URL 38 + ClientID string 39 + // Fully qualified callback URL 40 + CallbackURL string 41 + // Set of OAuth scope strings, which will be both declared in client metadata document and requested for every session. Must include "atproto". 42 + Scopes []string 43 + UserAgent string 44 + 45 + // For confidential clients, the private client assertion key. Note that while an interface is used here, only P-256 is allowed by the current specification. 46 + PrivateKey crypto.PrivateKey 47 + 48 + // ID for current client assertion key (should be provided if PrivateKey is) 49 + KeyID *string 50 + } 51 + 52 + // Constructs a [ClientApp] based on configuration. 53 + func NewClientApp(config *ClientConfig, store ClientAuthStore) *ClientApp { 54 + app := &ClientApp{ 55 + Client: http.DefaultClient, 56 + Resolver: NewResolver(), 57 + Dir: identity.DefaultDirectory(), 58 + Config: config, 59 + Store: store, 60 + } 61 + if config.UserAgent != "" { 62 + app.Resolver.UserAgent = config.UserAgent 63 + 64 + // unpack DefaultDirectory nested type and insert UserAgent (and log failure in case default types change) 65 + dirAgent := false 66 + cdir, ok := app.Dir.(*identity.CacheDirectory) 67 + if ok { 68 + bdir, ok := cdir.Inner.(*identity.BaseDirectory) 69 + if ok { 70 + dirAgent = true 71 + bdir.UserAgent = config.UserAgent 72 + } 73 + } 74 + if !dirAgent { 75 + slog.Info("OAuth ClientApp identity directory User-Agent not configured") 76 + } 77 + } 78 + return app 79 + } 80 + 81 + // Creates a basic [ClientConfig] for use as a public (non-confidential) client. To upgrade to a confidential client, use this method and then [ClientConfig.SetClientSecret()]. 82 + // 83 + // The "scopes" array must include "atproto". 84 + func NewPublicConfig(clientID, callbackURL string, scopes []string) ClientConfig { 85 + c := ClientConfig{ 86 + ClientID: clientID, 87 + CallbackURL: callbackURL, 88 + UserAgent: "indigo-sdk", 89 + Scopes: scopes, 90 + } 91 + return c 92 + } 93 + 94 + // Creates a basic [ClientConfig] for use with localhost developmnet. Such a client is always public (non-confidential). 95 + // 96 + // The "scopes" array must include "atproto". 97 + func NewLocalhostConfig(callbackURL string, scopes []string) ClientConfig { 98 + params := make(url.Values) 99 + params.Set("redirect_uri", callbackURL) 100 + params.Set("scope", scopeStr(scopes)) 101 + c := ClientConfig{ 102 + ClientID: fmt.Sprintf("http://localhost?%s", params.Encode()), 103 + CallbackURL: callbackURL, 104 + UserAgent: "indigo-sdk", 105 + Scopes: scopes, 106 + } 107 + return c 108 + } 109 + 110 + // Whether this is a "confidential" OAuth client (with configured client attestation key), versus "public" client. 111 + func (config *ClientConfig) IsConfidential() bool { 112 + return config.PrivateKey != nil && config.KeyID != nil 113 + } 114 + 115 + func (config *ClientConfig) SetClientSecret(priv crypto.PrivateKey, keyID string) error { 116 + switch priv.(type) { 117 + case *crypto.PrivateKeyP256: 118 + // pass 119 + case *crypto.PrivateKeyK256: 120 + return fmt.Errorf("only P-256 (ES256) private keys supported for atproto OAuth") 121 + default: 122 + return fmt.Errorf("unknown private key type: %T", priv) 123 + } 124 + config.PrivateKey = priv 125 + config.KeyID = &keyID 126 + return nil 127 + } 128 + 129 + // Returns a "JWKS" representation of public keys for the client. This can be returned as JSON, as part of client metadata. 130 + // 131 + // If the client does not have any keys (eg, public client), returns an empty set. 132 + func (config *ClientConfig) PublicJWKS() JWKS { 133 + 134 + jwks := JWKS{Keys: []crypto.JWK{}} 135 + 136 + // public client with no keys 137 + if config.PrivateKey == nil || config.KeyID == nil { 138 + return jwks 139 + } 140 + 141 + pub, err := config.PrivateKey.PublicKey() 142 + if err != nil { 143 + return jwks 144 + } 145 + jwk, err := pub.JWK() 146 + if err != nil { 147 + return jwks 148 + } 149 + jwk.KeyID = config.KeyID 150 + 151 + jwks.Keys = []crypto.JWK{*jwk} 152 + return jwks 153 + } 154 + 155 + // helper to turn a list of scope strings in to a single space-separated scope string 156 + func scopeStr(scopes []string) string { 157 + return strings.Join(scopes, " ") 158 + } 159 + 160 + // Returns a [ClientMetadata] struct with the required fields populated based on this client configuration. Clients may want to populate additional metadata fields on top of this response. 161 + // 162 + // NOTE: confidential clients currently must provide JWKSURI after the fact 163 + func (config *ClientConfig) ClientMetadata() ClientMetadata { 164 + m := ClientMetadata{ 165 + ClientID: config.ClientID, 166 + ApplicationType: strPtr("web"), 167 + GrantTypes: []string{"authorization_code", "refresh_token"}, 168 + Scope: scopeStr(config.Scopes), 169 + ResponseTypes: []string{"code"}, 170 + RedirectURIs: []string{config.CallbackURL}, 171 + DPoPBoundAccessTokens: true, 172 + TokenEndpointAuthMethod: "none", 173 + } 174 + if config.IsConfidential() { 175 + m.TokenEndpointAuthMethod = "private_key_jwt" 176 + // NOTE: the key type is always ES256 177 + m.TokenEndpointAuthSigningAlg = strPtr("ES256") 178 + 179 + // TODO: need to include 'use' or 'key_ops' for JWKS in the client metadata doc? 180 + //jwks := config.PublicJWKS() 181 + //m.JWKS = &jwks 182 + } 183 + return m 184 + } 185 + 186 + // High-level helper for fetching session data from store, based on account DID and session identifier. 187 + func (app *ClientApp) ResumeSession(ctx context.Context, did syntax.DID, sessionID string) (*ClientSession, error) { 188 + 189 + sd, err := app.Store.GetSession(ctx, did, sessionID) 190 + if err != nil { 191 + return nil, err 192 + } 193 + 194 + sess := ClientSession{ 195 + Client: app.Client, 196 + Config: app.Config, 197 + Data: sd, 198 + } 199 + 200 + // configure callback for updating session data 201 + if app.Store != nil { 202 + sess.PersistSessionCallback = func(ctx context.Context, data *ClientSessionData) { 203 + slog.Debug("storing updated session data", "did", data.AccountDID, "session_id", data.SessionID) 204 + err := app.Store.SaveSession(ctx, *data) 205 + if err != nil { 206 + slog.Error("failed to store updated session data", "did", data.AccountDID, "session_id", data.SessionID, "err", err) 207 + } 208 + } 209 + } 210 + 211 + // TODO: refactor this in to ClientAuthStore layer? 212 + priv, err := crypto.ParsePrivateMultibase(sd.DPoPPrivateKeyMultibase) 213 + if err != nil { 214 + return nil, err 215 + } 216 + sess.DPoPPrivateKey = priv 217 + return &sess, nil 218 + } 219 + 220 + type clientAssertionClaims struct { 221 + jwt.RegisteredClaims 222 + 223 + HTTPMethod string `json:"htm"` 224 + TargetURI string `json:"hti"` 225 + AccessTokenHash *string `json:"ath,omitempty"` 226 + Nonce *string `json:"nonce,omitempty"` 227 + } 228 + 229 + type dpopClaims struct { 230 + jwt.RegisteredClaims 231 + 232 + HTTPMethod string `json:"htm"` 233 + TargetURI string `json:"htu"` 234 + AccessTokenHash *string `json:"ath,omitempty"` 235 + Nonce *string `json:"nonce,omitempty"` 236 + } 237 + 238 + // Low-level helper to generate and sign an OAuth confidential client assertion token (JWT). 239 + func (cfg *ClientConfig) NewClientAssertion(authURL string) (string, error) { 240 + if !cfg.IsConfidential() { 241 + return "", fmt.Errorf("non-confidential client") 242 + } 243 + claims := clientAssertionClaims{ 244 + RegisteredClaims: jwt.RegisteredClaims{ 245 + Issuer: cfg.ClientID, 246 + Subject: cfg.ClientID, 247 + Audience: []string{authURL}, 248 + ID: secureRandomBase64(16), 249 + IssuedAt: jwt.NewNumericDate(time.Now()), 250 + }, 251 + } 252 + 253 + signingMethod, err := keySigningMethod(cfg.PrivateKey) 254 + if err != nil { 255 + return "", err 256 + } 257 + 258 + token := jwt.NewWithClaims(signingMethod, claims) 259 + token.Header["kid"] = cfg.KeyID 260 + return token.SignedString(cfg.PrivateKey) 261 + } 262 + 263 + // Creates a DPoP token (JWT) for use with an OAuth Auth Server (not to be used with Resource Server). The returned JWT is not bound to an Access Token (no 'ath'), and does not indicate an issuer ('iss'). 264 + // 265 + // This is used during initial auth request (PAR), initial token request, and subsequent refresh token requests. Note that a full [ClientSession] is not available in several of these circumstances, so this is a stand-alone function. 266 + func NewAuthDPoP(httpMethod, url, dpopNonce string, privKey crypto.PrivateKey) (string, error) { 267 + 268 + claims := dpopClaims{ 269 + HTTPMethod: httpMethod, 270 + TargetURI: url, 271 + RegisteredClaims: jwt.RegisteredClaims{ 272 + ID: secureRandomBase64(16), 273 + IssuedAt: jwt.NewNumericDate(time.Now()), 274 + ExpiresAt: jwt.NewNumericDate(time.Now().Add(jwtExpirationDuration)), 275 + }, 276 + } 277 + if dpopNonce != "" { 278 + claims.Nonce = &dpopNonce 279 + } 280 + 281 + keyMethod, err := keySigningMethod(privKey) 282 + if err != nil { 283 + return "", err 284 + } 285 + 286 + // TODO: parse/cache this public JWK, for efficiency 287 + pub, err := privKey.PublicKey() 288 + if err != nil { 289 + return "", err 290 + } 291 + pubJWK, err := pub.JWK() 292 + if err != nil { 293 + return "", err 294 + } 295 + 296 + token := jwt.NewWithClaims(keyMethod, claims) 297 + token.Header["typ"] = "dpop+jwt" 298 + token.Header["jwk"] = pubJWK 299 + return token.SignedString(privKey) 300 + } 301 + 302 + // attempts to read an HTTP response body as JSON, and determine an error reason. always closes the response body 303 + func parseAuthErrorReason(resp *http.Response, reqType string) string { 304 + defer resp.Body.Close() 305 + var errResp map[string]any 306 + if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { 307 + slog.Warn("auth server request failed", "request", reqType, "statusCode", resp.StatusCode, "err", err) 308 + return "unknown" 309 + } 310 + slog.Warn("auth server request failed", "request", reqType, "statusCode", resp.StatusCode, "body", errResp) 311 + return fmt.Sprintf("%s", errResp["error"]) 312 + } 313 + 314 + // Low-level helper to send PAR request to auth server, which involves starting PKCE and DPoP. 315 + func (app *ClientApp) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, scope, loginHint string) (*AuthRequestData, error) { 316 + 317 + parURL := authMeta.PushedAuthorizationRequestEndpoint 318 + state := secureRandomBase64(16) 319 + pkceVerifier := secureRandomBase64(48) 320 + 321 + // generate PKCE code challenge for use in PAR request 322 + codeChallenge := S256CodeChallenge(pkceVerifier) 323 + 324 + slog.Debug("preparing PAR", "client_id", app.Config.ClientID, "callback_url", app.Config.CallbackURL) 325 + body := PushedAuthRequest{ 326 + ClientID: app.Config.ClientID, 327 + State: state, 328 + RedirectURI: app.Config.CallbackURL, 329 + Scope: scope, 330 + ResponseType: "code", 331 + CodeChallenge: codeChallenge, 332 + CodeChallengeMethod: "S256", 333 + } 334 + 335 + if app.Config.IsConfidential() { 336 + // self-signed JWT using private key in client metadata (confidential client) 337 + assertionJWT, err := app.Config.NewClientAssertion(authMeta.Issuer) 338 + if err != nil { 339 + return nil, err 340 + } 341 + body.ClientAssertionType = ClientAssertionJWTBearer 342 + body.ClientAssertion = assertionJWT 343 + } 344 + 345 + if loginHint != "" { 346 + body.LoginHint = &loginHint 347 + } 348 + vals, err := query.Values(body) 349 + if err != nil { 350 + return nil, err 351 + } 352 + bodyBytes := []byte(vals.Encode()) 353 + 354 + // when starting a new session, we don't know the DPoP nonce yet 355 + dpopServerNonce := "" 356 + 357 + // create new key for the session 358 + dpopPrivKey, err := crypto.GeneratePrivateKeyP256() 359 + if err != nil { 360 + return nil, err 361 + } 362 + 363 + slog.Debug("sending auth request", "scope", scope, "state", state, "redirectURI", app.Config.CallbackURL) 364 + 365 + var resp *http.Response 366 + for range 2 { 367 + dpopJWT, err := NewAuthDPoP("POST", parURL, dpopServerNonce, dpopPrivKey) 368 + if err != nil { 369 + return nil, err 370 + } 371 + 372 + req, err := http.NewRequestWithContext(ctx, "POST", parURL, bytes.NewBuffer(bodyBytes)) 373 + if err != nil { 374 + return nil, err 375 + } 376 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 377 + req.Header.Set("DPoP", dpopJWT) 378 + 379 + resp, err = app.Client.Do(req) 380 + if err != nil { 381 + return nil, err 382 + } 383 + 384 + // update DPoP Nonce 385 + dpopServerNonce = resp.Header.Get("DPoP-Nonce") 386 + 387 + // check for an error condition caused by an out of date DPoP nonce 388 + // note that the HTTP status code would be 400 Bad Request on token endpoint, not 401 Unauthorized like it would be on Resource Server requests 389 + if resp.StatusCode == http.StatusBadRequest && dpopServerNonce != "" { 390 + // parseAuthErrorReason() always closes resp.Body 391 + reason := parseAuthErrorReason(resp, "PAR") 392 + if reason == "use_dpop_nonce" { 393 + // already updated nonce value above; loop around and try again 394 + continue 395 + } 396 + return nil, fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, reason) 397 + } 398 + 399 + // otherwise process result 400 + break 401 + } 402 + 403 + defer resp.Body.Close() 404 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 405 + reason := parseAuthErrorReason(resp, "PAR") 406 + return nil, fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, reason) 407 + } 408 + 409 + var parResp PushedAuthResponse 410 + if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil { 411 + return nil, fmt.Errorf("auth request (PAR) response failed to decode: %w", err) 412 + } 413 + 414 + parInfo := AuthRequestData{ 415 + State: state, 416 + AuthServerURL: authMeta.Issuer, 417 + Scope: scope, 418 + PKCEVerifier: pkceVerifier, 419 + RequestURI: parResp.RequestURI, 420 + AuthServerTokenEndpoint: authMeta.TokenEndpoint, 421 + DPoPAuthServerNonce: dpopServerNonce, 422 + DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(), 423 + } 424 + 425 + return &parInfo, nil 426 + } 427 + 428 + // Lower-level helper. This is usually invoked as part of [ProcessCallback]. 429 + func (app *ClientApp) SendInitialTokenRequest(ctx context.Context, authCode string, info AuthRequestData) (*TokenResponse, error) { 430 + 431 + body := InitialTokenRequest{ 432 + ClientID: app.Config.ClientID, 433 + RedirectURI: app.Config.CallbackURL, 434 + GrantType: "authorization_code", 435 + Code: authCode, 436 + CodeVerifier: info.PKCEVerifier, 437 + } 438 + 439 + if app.Config.IsConfidential() { 440 + clientAssertion, err := app.Config.NewClientAssertion(info.AuthServerURL) 441 + if err != nil { 442 + return nil, err 443 + } 444 + body.ClientAssertionType = &ClientAssertionJWTBearer 445 + body.ClientAssertion = &clientAssertion 446 + } 447 + 448 + dpopPrivKey, err := crypto.ParsePrivateMultibase(info.DPoPPrivateKeyMultibase) 449 + if err != nil { 450 + return nil, err 451 + } 452 + 453 + vals, err := query.Values(body) 454 + if err != nil { 455 + return nil, err 456 + } 457 + bodyBytes := []byte(vals.Encode()) 458 + 459 + dpopServerNonce := info.DPoPAuthServerNonce 460 + 461 + var resp *http.Response 462 + for range 2 { 463 + dpopJWT, err := NewAuthDPoP("POST", info.AuthServerTokenEndpoint, dpopServerNonce, dpopPrivKey) 464 + if err != nil { 465 + return nil, err 466 + } 467 + 468 + req, err := http.NewRequestWithContext(ctx, "POST", info.AuthServerTokenEndpoint, bytes.NewBuffer(bodyBytes)) 469 + if err != nil { 470 + return nil, err 471 + } 472 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 473 + req.Header.Set("DPoP", dpopJWT) 474 + 475 + resp, err = app.Client.Do(req) 476 + if err != nil { 477 + return nil, err 478 + } 479 + 480 + // check if a nonce was provided 481 + dpopNonceHdr := resp.Header.Get("DPoP-Nonce") 482 + if dpopNonceHdr != "" && dpopNonceHdr != dpopServerNonce { 483 + dpopServerNonce = dpopNonceHdr 484 + } 485 + 486 + // check for an error condition caused by an out of date DPoP nonce 487 + // note that the HTTP status code would be 400 Bad Request on token endpoint, not 401 Unauthorized like it would be on Resource Server requests 488 + if resp.StatusCode == http.StatusBadRequest && dpopNonceHdr != "" { 489 + // parseAuthErrorReason() always closes resp.Body 490 + reason := parseAuthErrorReason(resp, "initial-token") 491 + if reason == "use_dpop_nonce" { 492 + // already updated nonce value above; loop around and try again 493 + continue 494 + } 495 + return nil, fmt.Errorf("initial token request failed (HTTP %d): %s", resp.StatusCode, reason) 496 + } 497 + 498 + // otherwise process result 499 + break 500 + } 501 + 502 + defer resp.Body.Close() 503 + if resp.StatusCode != http.StatusOK { 504 + reason := parseAuthErrorReason(resp, "initial-token") 505 + return nil, fmt.Errorf("initial token request failed (HTTP %d): %s", resp.StatusCode, reason) 506 + } 507 + 508 + var tokenResp TokenResponse 509 + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { 510 + return nil, fmt.Errorf("token response failed to decode: %w", err) 511 + } 512 + 513 + return &tokenResp, nil 514 + } 515 + 516 + // High-level helper for starting a new session. Resolves identifier to resource server and auth server metadata, sends PAR request, persists request info to store, and returns a redirect URL. 517 + // 518 + // The `identifier` argument can be an atproto account identifier (handle or DID), or can be a URL to the account's auth server. 519 + // 520 + // The returned sting will be a web URL that the user should be redirected to (in browser) to approve the auth flow. 521 + func (app *ClientApp) StartAuthFlow(ctx context.Context, identifier string) (string, error) { 522 + 523 + var authserverURL string 524 + var accountDID syntax.DID 525 + 526 + if strings.HasPrefix(identifier, "https://") { 527 + authserverURL = identifier 528 + identifier = "" 529 + } else { 530 + atid, err := syntax.ParseAtIdentifier(identifier) 531 + if err != nil { 532 + return "", fmt.Errorf("not a valid account identifier (%s): %w", identifier, err) 533 + } 534 + ident, err := app.Dir.Lookup(ctx, *atid) 535 + if err != nil { 536 + return "", fmt.Errorf("failed to resolve username (%s): %w", identifier, err) 537 + } 538 + host := ident.PDSEndpoint() 539 + if host == "" { 540 + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 541 + } 542 + 543 + // TODO: logger on ClientApp? 544 + logger := slog.Default().With("did", ident.DID, "handle", ident.Handle, "host", host) 545 + logger.Debug("resolving to auth server metadata") 546 + authserverURL, err = app.Resolver.ResolveAuthServerURL(ctx, host) 547 + if err != nil { 548 + return "", fmt.Errorf("resolving auth server: %w", err) 549 + } 550 + } 551 + 552 + authserverMeta, err := app.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 553 + if err != nil { 554 + return "", fmt.Errorf("fetching auth server metadata: %w", err) 555 + } 556 + 557 + scope := scopeStr(app.Config.Scopes) 558 + info, err := app.SendAuthRequest(ctx, authserverMeta, scope, identifier) 559 + if err != nil { 560 + return "", fmt.Errorf("auth request failed: %w", err) 561 + } 562 + 563 + if accountDID != "" { 564 + info.AccountDID = &accountDID 565 + } 566 + 567 + // persist auth request info 568 + app.Store.SaveAuthRequestInfo(ctx, *info) 569 + 570 + params := url.Values{} 571 + params.Set("client_id", app.Config.ClientID) 572 + params.Set("request_uri", info.RequestURI) 573 + 574 + // AuthorizationEndpoint was already checked to be a clean URL 575 + // TODO: could do additional SSRF checks on the redirect domain here 576 + redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode()) 577 + return redirectURL, nil 578 + } 579 + 580 + // High-level helper for completing auth flow: verifies callback query parameters against persisted auth request info, makes initial token request to the auth server, validates account identifier, and persists session data. 581 + func (app *ClientApp) ProcessCallback(ctx context.Context, params url.Values) (*ClientSessionData, error) { 582 + 583 + state := params.Get("state") 584 + authserverURL := params.Get("iss") 585 + authCode := params.Get("code") 586 + if state == "" || authserverURL == "" || authCode == "" { 587 + return nil, fmt.Errorf("missing required query param") 588 + } 589 + 590 + info, err := app.Store.GetAuthRequestInfo(ctx, state) 591 + if err != nil { 592 + return nil, fmt.Errorf("loading auth request info: %w", err) 593 + } 594 + 595 + if info.State != state || info.AuthServerURL != authserverURL { 596 + return nil, fmt.Errorf("callback params don't match request info") 597 + } 598 + 599 + tokenResp, err := app.SendInitialTokenRequest(ctx, authCode, *info) 600 + if err != nil { 601 + return nil, fmt.Errorf("initial token request: %w", err) 602 + } 603 + 604 + // verify against account/server from start of login 605 + var accountDID syntax.DID 606 + var hostURL string 607 + if info.AccountDID != nil { 608 + // if we started with an account DID, verify it against the subject 609 + accountDID = *info.AccountDID 610 + if tokenResp.Subject != info.AccountDID.String() { 611 + return nil, fmt.Errorf("token subject didn't match original DID") 612 + } 613 + // identity lookup for PDS hostname; this should be cached 614 + ident, err := app.Dir.LookupDID(ctx, accountDID) 615 + if err != nil { 616 + return nil, err 617 + } 618 + hostURL = ident.PDSEndpoint() 619 + } else { 620 + // if we started with an auth server URL, resolve and verify the identity 621 + accountDID, err = syntax.ParseDID(tokenResp.Subject) 622 + if err != nil { 623 + return nil, err 624 + } 625 + ident, err := app.Dir.LookupDID(ctx, accountDID) 626 + if err != nil { 627 + return nil, err 628 + } 629 + hostURL = ident.PDSEndpoint() 630 + res, err := app.Resolver.ResolveAuthServerURL(ctx, hostURL) 631 + if err != nil { 632 + return nil, fmt.Errorf("resolving auth server: %w", err) 633 + } 634 + if res != authserverURL { 635 + return nil, fmt.Errorf("token subject auth server did not match original") 636 + } 637 + } 638 + 639 + sessData := ClientSessionData{ 640 + AccountDID: accountDID, 641 + SessionID: info.State, 642 + Scopes: strings.Split(tokenResp.Scope, " "), 643 + HostURL: hostURL, 644 + AuthServerURL: info.AuthServerURL, 645 + AccessToken: tokenResp.AccessToken, 646 + RefreshToken: tokenResp.RefreshToken, 647 + DPoPAuthServerNonce: info.DPoPAuthServerNonce, 648 + DPoPHostNonce: info.DPoPAuthServerNonce, // bootstrap host nonce from authserver 649 + DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase, 650 + } 651 + if err := app.Store.SaveSession(ctx, sessData); err != nil { 652 + return nil, err 653 + } 654 + if err := app.Store.DeleteAuthRequestInfo(ctx, state); err != nil { 655 + // only log on failure to delete state info 656 + slog.Warn("failed to delete auth request info", "state", state, "did", accountDID, "authserver", info.AuthServerURL, "err", err) 657 + } 658 + return &sessData, nil 659 + }
+170
atproto/auth/oauth/resolver.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/util/ssrf" 12 + ) 13 + 14 + // Helper for resolving OAuth documents from the public web: client metadata, auth server metadata, etc. 15 + // 16 + // NOTE: configurable caching will likely be added in the future, but is not implemented yet. This struct may become an interface to support more flexible caching and resolution policies. 17 + type Resolver struct { 18 + Client *http.Client 19 + UserAgent string 20 + } 21 + 22 + func NewResolver() *Resolver { 23 + c := http.Client{ 24 + Timeout: 10 * time.Second, 25 + Transport: ssrf.PublicOnlyTransport(), 26 + } 27 + return &Resolver{ 28 + Client: &c, 29 + UserAgent: "indigo-sdk", 30 + } 31 + } 32 + 33 + // Resolves a Resource Server URL (eg, an atproto account's registered PDS service URL) to an auth server URL (eg, entryway URL). They might be the same server! 34 + // 35 + // Ensures that the returned URL is valid (eg, parses as a URL). 36 + func (r *Resolver) ResolveAuthServerURL(ctx context.Context, hostURL string) (string, error) { 37 + u, err := url.Parse(hostURL) 38 + if err != nil { 39 + return "", err 40 + } 41 + // TODO: check against other resource server rules? 42 + if u.Scheme != "https" || u.Hostname() == "" || u.Port() != "" { 43 + return "", fmt.Errorf("not a valid public host URL: %s", hostURL) 44 + } 45 + 46 + docURL := fmt.Sprintf("https://%s/.well-known/oauth-protected-resource", u.Hostname()) 47 + 48 + // NOTE: this allows redirects 49 + req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil) 50 + if err != nil { 51 + return "", err 52 + } 53 + if r.UserAgent != "" { 54 + req.Header.Set("User-Agent", r.UserAgent) 55 + } 56 + 57 + resp, err := r.Client.Do(req) 58 + if err != nil { 59 + return "", fmt.Errorf("fetching protected resource document: %w", err) 60 + } 61 + defer resp.Body.Close() 62 + 63 + // intentionally check for exactly HTTP 200 (not just 2xx) 64 + if resp.StatusCode != http.StatusOK { 65 + return "", fmt.Errorf("HTTP error fetching protected resource document: %d", resp.StatusCode) 66 + } 67 + 68 + var body ProtectedResourceMetadata 69 + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 70 + return "", fmt.Errorf("invalid protected resource document: %w", err) 71 + } 72 + if len(body.AuthorizationServers) < 1 { 73 + return "", fmt.Errorf("no auth server URL in protected resource document") 74 + } 75 + authURL := body.AuthorizationServers[0] 76 + au, err := url.Parse(body.AuthorizationServers[0]) 77 + if err != nil { 78 + return "", fmt.Errorf("invalid auth server URL: %w", err) 79 + } 80 + if au.Scheme != "https" || au.Hostname() == "" || au.Port() != "" { 81 + return "", fmt.Errorf("not a valid public auth server URL: %s", authURL) 82 + } 83 + return authURL, nil 84 + } 85 + 86 + // Resolves an Auth Server URL to server metadata. Validates metadata before returning. 87 + func (r *Resolver) ResolveAuthServerMetadata(ctx context.Context, serverURL string) (*AuthServerMetadata, error) { 88 + u, err := url.Parse(serverURL) 89 + if err != nil { 90 + return nil, err 91 + } 92 + // TODO: check against other rules? 93 + if u.Scheme != "https" || u.Hostname() == "" || u.Port() != "" { 94 + return nil, fmt.Errorf("not a valid public host URL: %s", serverURL) 95 + } 96 + 97 + docURL := fmt.Sprintf("https://%s/.well-known/oauth-authorization-server", u.Hostname()) 98 + 99 + // NOTE: this allows redirects 100 + req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil) 101 + if err != nil { 102 + return nil, err 103 + } 104 + if r.UserAgent != "" { 105 + req.Header.Set("User-Agent", r.UserAgent) 106 + } 107 + 108 + resp, err := r.Client.Do(req) 109 + if err != nil { 110 + return nil, fmt.Errorf("fetching auth server metadata: %w", err) 111 + } 112 + defer resp.Body.Close() 113 + 114 + // NOTE: maybe any HTTP 2xx should be allowed? 115 + if resp.StatusCode != http.StatusOK { 116 + return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode) 117 + } 118 + 119 + var body AuthServerMetadata 120 + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 121 + return nil, fmt.Errorf("invalid protected resource document: %w", err) 122 + } 123 + 124 + if err := body.Validate(serverURL); err != nil { 125 + return nil, err 126 + } 127 + return &body, nil 128 + } 129 + 130 + // Fetches and validates OAuth client metadata document based on identifier in URL format. 131 + func (r *Resolver) ResolveClientMetadata(ctx context.Context, clientID string) (*ClientMetadata, error) { 132 + u, err := url.Parse(clientID) 133 + if err != nil { 134 + return nil, err 135 + } 136 + // TODO: check against other rules? 137 + if u.Scheme != "https" || u.Hostname() == "" || u.Port() != "" { 138 + return nil, fmt.Errorf("not a valid public host URL: %s", clientID) 139 + } 140 + 141 + // NOTE: this allows redirects 142 + req, err := http.NewRequestWithContext(ctx, "GET", clientID, nil) 143 + if err != nil { 144 + return nil, err 145 + } 146 + if r.UserAgent != "" { 147 + req.Header.Set("User-Agent", r.UserAgent) 148 + } 149 + 150 + resp, err := r.Client.Do(req) 151 + if err != nil { 152 + return nil, fmt.Errorf("fetching client metadata: %w", err) 153 + } 154 + defer resp.Body.Close() 155 + 156 + // NOTE: maybe any HTTP 2xx should be allowed? 157 + if resp.StatusCode != http.StatusOK { 158 + return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode) 159 + } 160 + 161 + var body ClientMetadata 162 + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 163 + return nil, fmt.Errorf("invalid client metadata document: %w", err) 164 + } 165 + 166 + if err := body.Validate(clientID); err != nil { 167 + return nil, err 168 + } 169 + return &body, nil 170 + }
+149
atproto/auth/oauth/resolver_test.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "os" 7 + "testing" 8 + 9 + "github.com/stretchr/testify/assert" 10 + ) 11 + 12 + // TODO: localhost (dev mode) resolution 13 + 14 + func TestValidateMetadata(t *testing.T) { 15 + assert := assert.New(t) 16 + 17 + { 18 + var meta ProtectedResourceMetadata 19 + b, err := os.ReadFile("testdata/morel-protected-resource.json") 20 + if err != nil { 21 + t.Fatal(err) 22 + } 23 + if err := json.Unmarshal(b, &meta); err != nil { 24 + t.Fatal(err) 25 + } 26 + } 27 + 28 + { 29 + var meta ProtectedResourceMetadata 30 + b, err := os.ReadFile("testdata/indie-protected-resource.json") 31 + if err != nil { 32 + t.Fatal(err) 33 + } 34 + if err := json.Unmarshal(b, &meta); err != nil { 35 + t.Fatal(err) 36 + } 37 + } 38 + 39 + { 40 + var meta AuthServerMetadata 41 + b, err := os.ReadFile("testdata/bsky-entryway-authorization-server.json") 42 + if err != nil { 43 + t.Fatal(err) 44 + } 45 + if err := json.Unmarshal(b, &meta); err != nil { 46 + t.Fatal(err) 47 + } 48 + assert.NoError(meta.Validate("https://bsky.social/.well-known/oauth-authorization-server")) 49 + } 50 + 51 + { 52 + var meta AuthServerMetadata 53 + b, err := os.ReadFile("testdata/indie-authorization-server.json") 54 + if err != nil { 55 + t.Fatal(err) 56 + } 57 + if err := json.Unmarshal(b, &meta); err != nil { 58 + t.Fatal(err) 59 + } 60 + assert.NoError(meta.Validate("https://pds.robocracy.org/.well-known/oauth-authorization-server")) 61 + } 62 + 63 + { 64 + var meta ClientMetadata 65 + b, err := os.ReadFile("testdata/flaskdemo-client-metadata.json") 66 + if err != nil { 67 + t.Fatal(err) 68 + } 69 + if err := json.Unmarshal(b, &meta); err != nil { 70 + t.Fatal(err) 71 + } 72 + assert.NoError(meta.Validate("https://oauth-flask.demo.bsky.dev/oauth/client-metadata.json")) 73 + } 74 + 75 + { 76 + var meta ClientMetadata 77 + b, err := os.ReadFile("testdata/statusphere-client-metadata.json") 78 + if err != nil { 79 + t.Fatal(err) 80 + } 81 + if err := json.Unmarshal(b, &meta); err != nil { 82 + t.Fatal(err) 83 + } 84 + assert.NoError(meta.Validate("https://statusphere.mozzius.dev/oauth-client-metadata.json")) 85 + } 86 + 87 + { 88 + var meta ClientMetadata 89 + b, err := os.ReadFile("testdata/smokesignal-client-metadata.json") 90 + if err != nil { 91 + t.Fatal(err) 92 + } 93 + if err := json.Unmarshal(b, &meta); err != nil { 94 + t.Fatal(err) 95 + } 96 + assert.NoError(meta.Validate("https://smokesignal.events/oauth/client-metadata.json")) 97 + } 98 + 99 + { 100 + var meta JWKS 101 + b, err := os.ReadFile("testdata/flaskdemo-jwks.json") 102 + if err != nil { 103 + t.Fatal(err) 104 + } 105 + if err := json.Unmarshal(b, &meta); err != nil { 106 + t.Fatal(err) 107 + } 108 + } 109 + 110 + { 111 + var meta JWKS 112 + b, err := os.ReadFile("testdata/smokesignal-jwks.json") 113 + if err != nil { 114 + t.Fatal(err) 115 + } 116 + if err := json.Unmarshal(b, &meta); err != nil { 117 + t.Fatal(err) 118 + } 119 + } 120 + } 121 + 122 + func TestResolver(t *testing.T) { 123 + assert := assert.New(t) 124 + ctx := context.Background() 125 + 126 + resolver := NewResolver() 127 + 128 + { 129 + // Live network tests (disabled by default) 130 + /* 131 + _, err := resolver.ResolveAuthServerURL(ctx, "https://morel.us-east.host.bsky.network") 132 + assert.NoError(err) 133 + _, err = resolver.ResolveAuthServerMetadata(ctx, "https://bsky.social") 134 + assert.NoError(err) 135 + _, err = resolver.ResolveClientMetadata(ctx, "https://oauth-flask.demo.bsky.dev/oauth/client-metadata.json") 136 + assert.NoError(err) 137 + */ 138 + } 139 + 140 + { 141 + // local unsafe should fail 142 + _, err := resolver.ResolveAuthServerURL(ctx, "https://127.0.0.1") 143 + assert.ErrorContains(err, "is not a public IP address") 144 + _, err = resolver.ResolveAuthServerMetadata(ctx, "https://10.0.0.1") 145 + assert.ErrorContains(err, "is not a public IP address") 146 + _, err = resolver.ResolveClientMetadata(ctx, "https://127.0.0.1/oauth/client-metadata.json") 147 + assert.ErrorContains(err, "is not a public IP address") 148 + } 149 + }
+357
atproto/auth/oauth/session.go
··· 1 + package oauth 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + "net/url" 11 + "strings" 12 + "sync" 13 + "time" 14 + 15 + "github.com/bluesky-social/indigo/atproto/client" 16 + "github.com/bluesky-social/indigo/atproto/crypto" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 18 + 19 + "github.com/golang-jwt/jwt/v5" 20 + "github.com/google/go-querystring/query" 21 + ) 22 + 23 + type PersistSessionCallback = func(ctx context.Context, data *ClientSessionData) 24 + 25 + // Persisted information about an OAuth session. Used to resume an active session. 26 + type ClientSessionData struct { 27 + // Account DID for this session. Assuming only one active session per account, this can be used as "primary key" for storing and retrieving this information. 28 + AccountDID syntax.DID `json:"account_did"` 29 + 30 + // Identifier to distinguish this particular session for the account. Server backends generally support multiple sessions for the same account. This package will re-use the random 'state' token from the auth flow as the session ID. 31 + SessionID string `json:"session_id"` 32 + 33 + // Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info. 34 + HostURL string `json:"host_url"` 35 + 36 + // Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info. 37 + AuthServerURL string `json:"authserver_url"` 38 + 39 + // Full token endpoint 40 + AuthServerTokenEndpoint string `json:"authserver_token_endpoint"` 41 + 42 + // The set of scopes approved for this session (returned in the initial token request) 43 + Scopes []string `json:"scopes"` 44 + 45 + // Token which can be used directly against host ("resource server", eg PDS) 46 + AccessToken string `json:"access_token"` 47 + 48 + // Token which can be sent to auth server (eg, PDS or entryway) to get a new access token 49 + RefreshToken string `json:"refresh_token"` 50 + 51 + // Current auth server DPoP nonce 52 + DPoPAuthServerNonce string `json:"dpop_authserver_nonce"` 53 + 54 + // Current host ("resource server", eg PDS) DPoP nonce 55 + DPoPHostNonce string `json:"dpop_host_nonce"` 56 + 57 + // The secret cryptographic key generated by the client for this specific OAuth session 58 + DPoPPrivateKeyMultibase string `json:"dpop_privatekey_multibase"` 59 + 60 + // TODO: also persist access token creation time / expiration time? In context that token might not be an easily parsed JWT 61 + } 62 + 63 + // Implementation of [client.AuthMethod] for an OAuth session. Handles DPoP request token signing and nonce rotation, and token refresh requests. Optionally uses a callback to persist updated session data. 64 + // 65 + // A single ClientSession instance can be called concurrently: updates to session data (the 'Data' field) are protected with a RW mutex lock. Note that concurrent calls to distinct ClientSession instances for the same session could result in clobbered session data. 66 + type ClientSession struct { 67 + // HTTP client used for token refresh requests 68 + Client *http.Client 69 + 70 + Config *ClientConfig 71 + Data *ClientSessionData 72 + DPoPPrivateKey crypto.PrivateKey 73 + 74 + PersistSessionCallback PersistSessionCallback 75 + 76 + // Lock which protects concurrent access to session data (eg, access and refresh tokens) 77 + lk sync.RWMutex 78 + } 79 + 80 + // Requests new tokens from auth server, and returns the new access token on success. 81 + // 82 + // Internally takes a lock on session data around the entire refresh process, including retries. Persists data using [ClientSession.PersistSessionCallback] if configured. 83 + func (sess *ClientSession) RefreshTokens(ctx context.Context) (string, error) { 84 + sess.lk.Lock() 85 + defer sess.lk.Unlock() 86 + 87 + body := RefreshTokenRequest{ 88 + ClientID: sess.Config.ClientID, 89 + GrantType: "authorization_code", 90 + RefreshToken: sess.Data.RefreshToken, 91 + } 92 + tokenURL := sess.Data.AuthServerTokenEndpoint 93 + 94 + if sess.Config.IsConfidential() { 95 + clientAssertion, err := sess.Config.NewClientAssertion(sess.Data.AuthServerURL) 96 + if err != nil { 97 + return "", err 98 + } 99 + body.ClientAssertionType = &ClientAssertionJWTBearer 100 + body.ClientAssertion = &clientAssertion 101 + } 102 + 103 + vals, err := query.Values(body) 104 + if err != nil { 105 + return "", err 106 + } 107 + bodyBytes := []byte(vals.Encode()) 108 + 109 + var resp *http.Response 110 + for range 2 { 111 + dpopJWT, err := NewAuthDPoP("POST", sess.Data.AuthServerTokenEndpoint, sess.Data.DPoPAuthServerNonce, sess.DPoPPrivateKey) 112 + if err != nil { 113 + return "", err 114 + } 115 + 116 + req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, bytes.NewBuffer(bodyBytes)) 117 + if err != nil { 118 + return "", err 119 + } 120 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 121 + req.Header.Set("DPoP", dpopJWT) 122 + 123 + resp, err = sess.Client.Do(req) 124 + if err != nil { 125 + return "", err 126 + } 127 + 128 + // always check if a new DPoP nonce was provided, and proactively update session data (even if there was not an explicit error) 129 + dpopNonceHdr := resp.Header.Get("DPoP-Nonce") 130 + if dpopNonceHdr != "" && dpopNonceHdr != sess.Data.DPoPAuthServerNonce { 131 + sess.Data.DPoPAuthServerNonce = dpopNonceHdr 132 + } 133 + 134 + // check for an error condition caused by an out of date DPoP nonce 135 + // note that the HTTP status code is 400 Bad Request on the Auth Server token endpoint, not 401 Unauthorized like it would be on Resource Server requests 136 + if resp.StatusCode == http.StatusBadRequest && dpopNonceHdr != "" { 137 + // parseAuthErrorReason() always closes resp.Body 138 + reason := parseAuthErrorReason(resp, "token-refresh") 139 + if reason == "use_dpop_nonce" { 140 + // already updated nonce value above; loop around and try again 141 + continue 142 + } 143 + return "", fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, reason) 144 + } 145 + 146 + // otherwise process response (success or other error type) 147 + break 148 + } 149 + 150 + defer resp.Body.Close() 151 + if resp.StatusCode != http.StatusOK { 152 + reason := parseAuthErrorReason(resp, "token-refresh") 153 + return "", fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, reason) 154 + } 155 + 156 + var tokenResp TokenResponse 157 + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { 158 + return "", fmt.Errorf("token response failed to decode: %w", err) 159 + } 160 + // TODO: more validation of token refresh response? 161 + 162 + sess.Data.AccessToken = tokenResp.AccessToken 163 + sess.Data.RefreshToken = tokenResp.RefreshToken 164 + 165 + // persist updated data (tokens and possibly nonce) 166 + if sess.PersistSessionCallback != nil { 167 + sess.PersistSessionCallback(ctx, sess.Data) 168 + } else { 169 + slog.Warn("not saving updated session data", "did", sess.Data.AccountDID, "session_id", sess.Data.SessionID) 170 + } 171 + 172 + return sess.Data.AccessToken, nil 173 + } 174 + 175 + // Constructs and signs a DPoP JWT to include in request header to Host (aka Resource Server, aka PDS). These tokens are different from those used with Auth Server token endpoints (even if the PDS is filling both roles) 176 + func (sess *ClientSession) NewHostDPoP(method, reqURL string) (string, error) { 177 + sess.lk.RLock() 178 + defer sess.lk.RUnlock() 179 + 180 + ath := S256CodeChallenge(sess.Data.AccessToken) 181 + claims := dpopClaims{ 182 + HTTPMethod: method, 183 + TargetURI: reqURL, 184 + AccessTokenHash: &ath, 185 + RegisteredClaims: jwt.RegisteredClaims{ 186 + Issuer: sess.Data.AuthServerURL, 187 + ID: secureRandomBase64(16), 188 + IssuedAt: jwt.NewNumericDate(time.Now()), 189 + ExpiresAt: jwt.NewNumericDate(time.Now().Add(jwtExpirationDuration)), 190 + }, 191 + } 192 + if sess.Data.DPoPHostNonce != "" { 193 + claims.Nonce = &sess.Data.DPoPHostNonce 194 + } 195 + 196 + keyMethod, err := keySigningMethod(sess.DPoPPrivateKey) 197 + if err != nil { 198 + return "", err 199 + } 200 + 201 + // TODO: store a copy of this JWK on the ClientSession as a private field, for efficiency 202 + pub, err := sess.DPoPPrivateKey.PublicKey() 203 + if err != nil { 204 + return "", err 205 + } 206 + pubJWK, err := pub.JWK() 207 + if err != nil { 208 + return "", err 209 + } 210 + 211 + token := jwt.NewWithClaims(keyMethod, claims) 212 + token.Header["typ"] = "dpop+jwt" 213 + token.Header["jwk"] = pubJWK 214 + return token.SignedString(sess.DPoPPrivateKey) 215 + } 216 + 217 + // copy a request URL and strip query params and fragment, for DPoP 218 + func dpopURL(u *url.URL) string { 219 + u2 := *u 220 + u2.RawQuery = "" 221 + u2.ForceQuery = false 222 + u2.Fragment = "" 223 + u2.RawFragment = "" 224 + return u2.String() 225 + } 226 + 227 + // Parses a WWW-Authenticate response header to see if DPoP nonce update is indicated 228 + func isNonceUpdateHeader(hdr string) bool { 229 + // Example from RFC9449: 230 + // WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof" 231 + return strings.Contains(hdr, "error=\"use_dpop_nonce\"") 232 + } 233 + 234 + // Parses a WWW-Authenticate response header to see if access token has expired (needs refresh) 235 + func isExpiredAccessTokenHeader(hdr string) bool { 236 + // Example from OAuth 2.1 draft: 237 + // WWW-Authenticate: Bearer error="invalid_token" error_description="The access token expired" 238 + // TODO: should this also look for "expired"? 239 + return strings.Contains(hdr, "error=\"invalid_token\"") 240 + } 241 + 242 + func (sess *ClientSession) GetHostAccessData() (accessToken string, dpopHostNonce string) { 243 + sess.lk.RLock() 244 + defer sess.lk.RUnlock() 245 + 246 + return sess.Data.AccessToken, sess.Data.DPoPHostNonce 247 + } 248 + 249 + func (sess *ClientSession) UpdateHostDPoPNonce(ctx context.Context, nonce string) { 250 + sess.lk.Lock() 251 + defer sess.lk.Unlock() 252 + 253 + sess.Data.DPoPHostNonce = nonce 254 + 255 + if sess.PersistSessionCallback != nil { 256 + sess.PersistSessionCallback(ctx, sess.Data) 257 + } else { 258 + slog.Warn("not saving updated host DPoP nonce", "did", sess.Data.AccountDID, "session_id", sess.Data.SessionID) 259 + } 260 + } 261 + 262 + // Sends API request to OAuth Resource Server (PDS), using access token and DPoP. 263 + // 264 + // Automatically handles DPoP nonce updates and token refresh as needed, based on the response status code and `WWW-Authenticate` header. 265 + func (sess *ClientSession) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) { 266 + 267 + durl := dpopURL(req.URL) 268 + 269 + accessToken, dpopNonce := sess.GetHostAccessData() 270 + 271 + // this method may need to retry twice, once for DPoP nonce update and once for token refresh 272 + var resp *http.Response 273 + for range 3 { 274 + dpopJWT, err := sess.NewHostDPoP(req.Method, durl) 275 + if err != nil { 276 + return nil, err 277 + } 278 + req.Header.Set("Authorization", fmt.Sprintf("DPoP %s", accessToken)) 279 + req.Header.Set("DPoP", dpopJWT) 280 + 281 + resp, err = c.Do(req) 282 + if err != nil { 283 + return nil, err 284 + } 285 + 286 + // on Success, or many types of error, just return HTTP response 287 + // "Unauthorized" is HTTP status code 401 288 + if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("WWW-Authenticate") == "" { 289 + return resp, nil 290 + } 291 + 292 + authHdr := resp.Header.Get("WWW-Authenticate") 293 + dpopNonceHdr := resp.Header.Get("DPoP-Nonce") 294 + 295 + // if DPoP nonce changed, update and retry request 296 + if isNonceUpdateHeader(authHdr) && dpopNonceHdr != "" { 297 + // TODO: validate or normalize dpopNonceHdr in some way? eg minimum length 298 + if dpopNonceHdr == dpopNonce { 299 + return nil, fmt.Errorf("OAuth PDS DPoP nonce failure, but no new nonce supplied") 300 + } 301 + 302 + // persist new nonce value via callback 303 + sess.UpdateHostDPoPNonce(req.Context(), dpopNonceHdr) 304 + dpopNonce = dpopNonceHdr 305 + 306 + // retry request 307 + retry := req.Clone(req.Context()) 308 + if req.GetBody != nil { 309 + retry.Body, err = req.GetBody() 310 + if err != nil { 311 + return nil, fmt.Errorf("GetBody failed when retrying API request: %w", err) 312 + } 313 + } 314 + req = retry 315 + continue 316 + } 317 + 318 + // if access token expired, refresh and retry 319 + if isExpiredAccessTokenHeader(authHdr) { 320 + accessToken, err = sess.RefreshTokens(req.Context()) 321 + if err != nil { 322 + return nil, fmt.Errorf("failed to refresh OAuth tokens: %w", err) 323 + } 324 + 325 + retry := req.Clone(req.Context()) 326 + if req.GetBody != nil { 327 + retry.Body, err = req.GetBody() 328 + if err != nil { 329 + return nil, fmt.Errorf("GetBody failed when retrying API request: %w", err) 330 + } 331 + } 332 + req = retry 333 + continue 334 + } 335 + 336 + // otherwise, this was some other type of auth failure; just return the full response 337 + // NOTE: in theory we could return an APIError here instead 338 + return resp, nil 339 + } 340 + 341 + return nil, fmt.Errorf("OAuth client ran out of request retries") 342 + } 343 + 344 + // Creates a new [client.APIClient] which wraps this session for auth. 345 + func (sess *ClientSession) APIClient() *client.APIClient { 346 + c := client.APIClient{ 347 + Client: sess.Client, 348 + Host: sess.Data.HostURL, 349 + Auth: sess, 350 + AccountDID: &sess.Data.AccountDID, 351 + } 352 + if sess.Config.UserAgent != "" { 353 + c.Headers = make(map[string][]string) 354 + c.Headers.Set("User-Agent", sess.Config.UserAgent) 355 + } 356 + return &c 357 + }
+24
atproto/auth/oauth/store.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + // Interface for persisting session data and auth request data, required as part of an OAuth client app. 10 + // 11 + // This interface supports multiple sessions for a single account (DID). This is helpful for traditional web app backends where a single user might log in and have concurrent sessions from multiple browsers/devices. For situations where multiple sessions are not required, implementations of this interface could ignore the `sessionID` parameters, though this could result in clobbering of active sessions. 12 + // 13 + // For authorization-only (authn-only) applications, the `SaveSession()` method could be a no-op. 14 + // 15 + // Implementations should generally allow for concurrent access. 16 + type ClientAuthStore interface { 17 + GetSession(ctx context.Context, did syntax.DID, sessionID string) (*ClientSessionData, error) 18 + SaveSession(ctx context.Context, sess ClientSessionData) error 19 + DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error 20 + 21 + GetAuthRequestInfo(ctx context.Context, state string) (*AuthRequestData, error) 22 + SaveAuthRequestInfo(ctx context.Context, info AuthRequestData) error 23 + DeleteAuthRequestInfo(ctx context.Context, state string) error 24 + }
+1
atproto/auth/oauth/testdata/bsky-entryway-authorization-server.json
··· 1 + {"issuer":"https://bsky.social","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://bsky.social/oauth/jwks","authorization_endpoint":"https://bsky.social/oauth/authorize","token_endpoint":"https://bsky.social/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://bsky.social/oauth/revoke","introspection_endpoint":"https://bsky.social/oauth/introspect","pushed_authorization_request_endpoint":"https://bsky.social/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"client_id_metadata_document_supported":true}
+1
atproto/auth/oauth/testdata/flaskdemo-client-metadata.json
··· 1 + {"application_type":"web","client_id":"https://oauth-flask.demo.bsky.dev/oauth/client-metadata.json","client_name":"atproto OAuth Flask Backend Demo","client_uri":"https://oauth-flask.demo.bsky.dev/","dpop_bound_access_tokens":true,"grant_types":["authorization_code","refresh_token"],"jwks_uri":"https://oauth-flask.demo.bsky.dev/oauth/jwks.json","redirect_uris":["https://oauth-flask.demo.bsky.dev/oauth/callback"],"response_types":["code"],"scope":"atproto transition:generic","token_endpoint_auth_method":"private_key_jwt","token_endpoint_auth_signing_alg":"ES256"}
+1
atproto/auth/oauth/testdata/flaskdemo-jwks.json
··· 1 + {"keys":[{"crv":"P-256","kid":"demo-1723096995","kty":"EC","x":"9xgcqvuBJIN8M32cvXc7vZDc4xgaYvrEMh8LHH1Uz0E","y":"ar8LYDXhUp32aNjq-Ko5jKVFUZNLSYxm7okrU2KIUqk"}]}
+1
atproto/auth/oauth/testdata/indie-authorization-server.json
··· 1 + {"issuer":"https://pds.robocracy.org","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://pds.robocracy.org/oauth/jwks","authorization_endpoint":"https://pds.robocracy.org/oauth/authorize","token_endpoint":"https://pds.robocracy.org/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://pds.robocracy.org/oauth/revoke","introspection_endpoint":"https://pds.robocracy.org/oauth/introspect","pushed_authorization_request_endpoint":"https://pds.robocracy.org/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"protected_resources":["https://pds.robocracy.org"],"client_id_metadata_document_supported":true}
+1
atproto/auth/oauth/testdata/indie-protected-resource.json
··· 1 + {"resource":"https://pds.robocracy.org","authorization_servers":["https://pds.robocracy.org"],"scopes_supported":[],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}
+1
atproto/auth/oauth/testdata/morel-protected-resource.json
··· 1 + {"resource":"https://morel.us-east.host.bsky.network","authorization_servers":["https://bsky.social"],"scopes_supported":[],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}
+1
atproto/auth/oauth/testdata/smokesignal-client-metadata.json
··· 1 + {"client_id":"https://smokesignal.events/oauth/client-metadata.json","dpop_bound_access_tokens":true,"application_type":"web","redirect_uris":["https://smokesignal.events/oauth/callback"],"client_uri":"https://smokesignal.events","subject_type":"public","grant_types":["authorization_code","refresh_token"],"response_types":["code"],"scope":"atproto transition:generic","client_name":"Smoke Signal","token_endpoint_auth_method":"none","jwks_uri":"https://smokesignal.events/oauth/jwks.json","logo_uri":"https://smokesignal.events/static/logo-160x160x.png","tos_uri":"https://docs.smokesignal.events/docs/about/terms/","policy_uri":"https://docs.smokesignal.events/docs/about/privacy/"}
+1
atproto/auth/oauth/testdata/smokesignal-jwks.json
··· 1 + {"keys":[{"kid":"01J5P69KNBQ2D08GBND5REQ5X6","alg":"ES256","kty":"EC","crv":"P-256","x":"1-Phkmhnqd8OzMezLc_iPeNFjyhVZtpm85FliOjbuRI","y":"7qxZDJ9UVBOR2AcF1IUSnD784vlVeoSA18O2XDGxL4U"},{"kid":"01J5P69N6XZTDEHMSBH8VJPYTV","alg":"ES256","kty":"EC","crv":"P-256","x":"xzk6X6PQ-r5jCoEPwAbBDO0bvG6Zy5TQWSDNLox6eQg","y":"LXg2ubRJ6uiqTNYQ9Pns1wEmtxyGAwUokTj5O3Ws92E"}]}
+1
atproto/auth/oauth/testdata/statusphere-client-metadata.json
··· 1 + {"redirect_uris":["https://statusphere.mozzius.dev/oauth/callback"],"response_types":["code"],"grant_types":["authorization_code","refresh_token"],"scope":"atproto transition:generic","token_endpoint_auth_method":"none","application_type":"web","client_id":"https://statusphere.mozzius.dev/oauth-client-metadata.json","client_name":"Statusphere React App","client_uri":"https://statusphere.mozzius.dev","dpop_bound_access_tokens":true}
+409
atproto/auth/oauth/types.go
··· 1 + package oauth 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/url" 7 + "slices" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/crypto" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + var ClientAssertionJWTBearer string = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 15 + 16 + var ( 17 + ErrInvalidAuthServerMetadata = errors.New("invalid auth server metadata") 18 + ErrInvalidClientMetadata = errors.New("invalid client metadata doc") 19 + ) 20 + 21 + type JWKS struct { 22 + Keys []crypto.JWK `json:"keys"` 23 + } 24 + 25 + // Expected response type from looking up OAuth Protected Resource information on a server (eg, a PDS instance) 26 + type ProtectedResourceMetadata struct { 27 + // are there other fields worth including? 28 + 29 + AuthorizationServers []string `json:"authorization_servers"` 30 + } 31 + 32 + type ClientMetadata struct { 33 + // Must exactly match the full URL used to fetch the client metadata file itself 34 + ClientID string `json:"client_id"` 35 + 36 + // Must be one of `web` or `native`, with `web` as the default if not specified. 37 + ApplicationType *string `json:"application_type,omitempty"` 38 + 39 + // `authorization_code` must always be included. `refresh_token` is optional, but must be included if the client will make token refresh requests. 40 + GrantTypes []string `json:"grant_types"` 41 + 42 + // All scope values which might be requested by the client are declared here. The `atproto` scope is required, so must be included here. 43 + Scope string `json:"scope"` 44 + 45 + // `code` must be included 46 + ResponseTypes []string `json:"response_types"` 47 + 48 + // At least one redirect URI is required. 49 + RedirectURIs []string `json:"redirect_uris"` 50 + 51 + // Confidential clients must set this to `private_key_jwt`; public must be `none`. 52 + // In some sense this field is "optional" (including in atproto OAuth specs), but it is effectively required, because the default value is invalid for atproto OAuth. 53 + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 54 + 55 + // `none` is never allowed here. The current recommended and most-supported algorithm is ES256, but this may evolve over time. 56 + TokenEndpointAuthSigningAlg *string `json:"token_endpoint_auth_signing_alg,omitempty"` 57 + 58 + // DPoP is mandatory for all clients, so this must be present and true 59 + DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 60 + 61 + // confidential clients must supply at least one public key in JWK format for use with JWT client authentication. Either this field or the `jwks_uri` field must be provided for confidential clients, but not both. 62 + JWKS *JWKS `json:"jwks,omitempty"` 63 + 64 + // URL pointing to a JWKS JSON object. See `jwks` above for details. 65 + JWKSURI *string `json:"jwks_uri,omitempty"` 66 + 67 + // human-readable name of the client 68 + ClientName *string `json:"client_name,omitempty"` 69 + 70 + // not to be confused with client_id, this is a homepage URL for the client. If provided, the client_uri must have the same hostname as client_id. 71 + ClientURI *string `json:"client_uri,omitempty"` 72 + 73 + // URL to client logo. Only https: URIs are allowed. 74 + LogoURI *string `json:"logo_uri,omitempty"` 75 + 76 + // URL to human-readable terms of service (ToS) for the client. Only https: URIs are allowed. 77 + TosURI *string `json:"tos_uri,omitempty"` 78 + 79 + // URL to human-readable privacy policy for the client. Only https: URIs are allowed. 80 + PolicyURI *string `json:"policy_uri,omitempty"` 81 + } 82 + 83 + // returns 'true' if client metadata indicates that this is a confidential client 84 + func (m *ClientMetadata) IsConfidential() bool { 85 + if (m.JWKSURI != nil || (m.JWKS != nil && len(m.JWKS.Keys) > 0)) && m.TokenEndpointAuthMethod == "private_key_jwt" { 86 + return true 87 + } 88 + 89 + return false 90 + } 91 + 92 + func (m *ClientMetadata) Validate(clientID string) error { 93 + 94 + if m.ClientID == "" || m.ClientID != clientID { 95 + return fmt.Errorf("%w: client_id", ErrInvalidClientMetadata) 96 + } 97 + 98 + if m.ApplicationType != nil && !slices.Contains([]string{"web", "native"}, *m.ApplicationType) { 99 + return fmt.Errorf("%w: application_type must be 'web', 'native', or undefined", ErrInvalidClientMetadata) 100 + } 101 + 102 + if !slices.Contains(m.GrantTypes, "authorization_code") { 103 + return fmt.Errorf("%w: grant_type must include 'authorization_code'", ErrInvalidClientMetadata) 104 + } 105 + 106 + scopes := strings.Split(m.Scope, " ") 107 + if !slices.Contains(scopes, "atproto") { 108 + return fmt.Errorf("%w: scope must include 'atproto'", ErrInvalidClientMetadata) 109 + } 110 + 111 + if !slices.Contains(m.ResponseTypes, "code") { 112 + return fmt.Errorf("%w: response_types must include 'code'", ErrInvalidClientMetadata) 113 + } 114 + 115 + if len(m.RedirectURIs) == 0 { 116 + return fmt.Errorf("%w: redirect_uris must have at least one element", ErrInvalidClientMetadata) 117 + } 118 + 119 + // 'web' redirect URLs have more restrictions 120 + if m.ApplicationType == nil || *m.ApplicationType == "web" { 121 + for _, ru := range m.RedirectURIs { 122 + u, err := url.Parse(ru) 123 + if err != nil { 124 + return fmt.Errorf("%w: invalid web redirect_uris: %w", ErrInvalidClientMetadata, err) 125 + } 126 + if u.Scheme != "https" && u.Hostname() != "127.0.0.1" { 127 + return fmt.Errorf("%w: web redirect_uris must have 'https' scheme", ErrInvalidClientMetadata) 128 + } 129 + } 130 + } 131 + 132 + if !(m.TokenEndpointAuthMethod == "none" || m.TokenEndpointAuthMethod == "private_key_jwt") { 133 + return fmt.Errorf("%w: unsupported token_endpoint_auth_method", ErrInvalidClientMetadata) 134 + } 135 + 136 + if m.TokenEndpointAuthSigningAlg != nil && *m.TokenEndpointAuthSigningAlg == "none" { 137 + // NOTE: what if this is a public client? 138 + return fmt.Errorf("%w: token_endpoint_auth_signing_alg must not be 'none'", ErrInvalidClientMetadata) 139 + } 140 + 141 + if !m.DPoPBoundAccessTokens { 142 + return fmt.Errorf("%w: dpop_bound_access_tokens must be true (DPoP is required)", ErrInvalidClientMetadata) 143 + } 144 + 145 + if m.JWKSURI != nil && *m.JWKSURI == "" { 146 + return fmt.Errorf("%w: jwks_uri must be valid URL (when provided)", ErrInvalidClientMetadata) 147 + } 148 + 149 + // NOTE: metadata URLs are not validated (they are not an error for overall metadata doc) 150 + 151 + return nil 152 + } 153 + 154 + type AuthServerMetadata struct { 155 + 156 + // the "origin" URL of the Authorization Server. Must be a valid URL, with https scheme. A port number is allowed (if that matches the origin), but the default port (443 for HTTPS) must not be specified. There must be no path segments. Must match the origin of the URL used to fetch the metadata document itself. 157 + Issuer string `json:"issuer"` 158 + 159 + // endpoint URL for authorization redirects 160 + AuthorizationEndpoint string `json:"authorization_endpoint"` 161 + 162 + // endpoint URL for token requests 163 + TokenEndpoint string `json:"token_endpoint"` 164 + 165 + // must include code 166 + ResponseTypesSupported []string `json:"response_types_supported"` 167 + 168 + // must include authorization_code and refresh_token (refresh tokens must be supported) 169 + GrantTypesSupported []string `json:"grant_types_supported"` 170 + 171 + // must include S256 172 + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` 173 + 174 + // must include both none (public clients) and private_key_jwt (confidential clients) 175 + TokenEndpointAuthMethodsSupoorted []string `json:"token_endpoint_auth_methods_supported"` 176 + 177 + // must not include `none`. Must include ES256 for now. 178 + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"` 179 + 180 + // must include atproto. If supporting the transitional grants, they should be included here as well. 181 + ScopesSupported []string `json:"scopes_supported"` 182 + 183 + // must be true 184 + AuthorizationReponseISSParameterSupported bool `json:"authorization_response_iss_parameter_supported"` 185 + 186 + // must be true 187 + RequirePushedAuthorizationRequests bool `json:"require_pushed_authorization_requests"` 188 + 189 + // corresponds to the PAR endpoint URL 190 + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` 191 + 192 + // currently must include ES256 193 + DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"` 194 + 195 + // default is true; does not need to be set explicitly, but must not be false 196 + RequireRequestURIRegistration *bool `json:"require_request_uri_registration,omitempty"` 197 + 198 + // must be true 199 + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` 200 + } 201 + 202 + func (m *AuthServerMetadata) Validate(serverURL string) error { 203 + 204 + if m.Issuer == "" { 205 + return fmt.Errorf("%w: empty issuer", ErrInvalidAuthServerMetadata) 206 + } 207 + u, err := url.Parse(m.Issuer) 208 + if err != nil { 209 + return fmt.Errorf("%w: invalid issuer URL: %w", ErrInvalidAuthServerMetadata, err) 210 + } 211 + if u.Scheme != "https" || u.Port() != "" || u.Path != "" || u.Fragment != "" || u.RawQuery != "" { 212 + return fmt.Errorf("%w: issuer URL", ErrInvalidAuthServerMetadata) 213 + } 214 + 215 + // check that Issuer matches domain this metadata document was fetched from 216 + srvu, err := url.Parse(serverURL) 217 + if err != nil { 218 + return fmt.Errorf("%w: invalid request URL: %w", ErrInvalidAuthServerMetadata, err) 219 + } 220 + if u.Scheme != srvu.Scheme || u.Host != srvu.Host { 221 + return fmt.Errorf("%w: issuer must match request URL", ErrInvalidAuthServerMetadata) 222 + } 223 + 224 + // check that authorization endpoint is a valid HTTPS URL with no fragment or query params (we will be appending query params latter) 225 + aeurl, err := url.Parse(m.AuthorizationEndpoint) 226 + if err != nil { 227 + return fmt.Errorf("%w: invalid auth endpoint URL (%s): %w", ErrInvalidAuthServerMetadata, m.AuthorizationEndpoint, err) 228 + } 229 + if aeurl.Scheme != "https" || u.Fragment != "" || u.RawQuery != "" { 230 + return fmt.Errorf("%w: invalid auth endpoint URL: %s", ErrInvalidAuthServerMetadata, m.AuthorizationEndpoint) 231 + } 232 + 233 + if !slices.Contains(m.ResponseTypesSupported, "code") { 234 + return fmt.Errorf("%w: response_types_supported must include 'code'", ErrInvalidAuthServerMetadata) 235 + } 236 + if !slices.Contains(m.GrantTypesSupported, "authorization_code") { 237 + return fmt.Errorf("%w: grant_types_supported must include 'authorization_code'", ErrInvalidAuthServerMetadata) 238 + } 239 + if !slices.Contains(m.GrantTypesSupported, "refresh_token") { 240 + return fmt.Errorf("%w: grant_types_supported must include 'refresh_token'", ErrInvalidAuthServerMetadata) 241 + } 242 + if !slices.Contains(m.CodeChallengeMethodsSupported, "S256") { 243 + return fmt.Errorf("%w: code_challenge_method must include 'S256'", ErrInvalidAuthServerMetadata) 244 + } 245 + if !slices.Contains(m.TokenEndpointAuthMethodsSupoorted, "none") { 246 + return fmt.Errorf("%w: token_endpoint_auth_methods_supported must include 'none'", ErrInvalidAuthServerMetadata) 247 + } 248 + if !slices.Contains(m.TokenEndpointAuthMethodsSupoorted, "private_key_jwt") { 249 + return fmt.Errorf("%w: token_endpoint_auth_methods_supported must include 'private_key_jwt'", ErrInvalidAuthServerMetadata) 250 + } 251 + if !slices.Contains(m.TokenEndpointAuthSigningAlgValuesSupported, "ES256") { 252 + return fmt.Errorf("%w: token_endpoint_auth_signing_alg_values_supported must include 'ES256'", ErrInvalidAuthServerMetadata) 253 + } 254 + if !slices.Contains(m.ScopesSupported, "atproto") { 255 + return fmt.Errorf("%w: scopes_supported must include 'atproto'", ErrInvalidAuthServerMetadata) 256 + } 257 + if !m.AuthorizationReponseISSParameterSupported { 258 + return fmt.Errorf("%w: authorization_response_iss_parameter_supported must be true", ErrInvalidAuthServerMetadata) 259 + } 260 + if !m.RequirePushedAuthorizationRequests { 261 + return fmt.Errorf("%w: require_pushed_authorization_requests must be true", ErrInvalidAuthServerMetadata) 262 + } 263 + if m.PushedAuthorizationRequestEndpoint == "" { 264 + return fmt.Errorf("%w: pushed_authorization_request_endpoint is required", ErrInvalidAuthServerMetadata) 265 + } 266 + if !slices.Contains(m.DPoPSigningAlgValuesSupported, "ES256") { 267 + return fmt.Errorf("%w: dpop_signing_alg_values_supported must include 'ES256'", ErrInvalidAuthServerMetadata) 268 + } 269 + if m.RequireRequestURIRegistration != nil && *m.RequireRequestURIRegistration != true { 270 + return fmt.Errorf("%w: require_request_uri_registration must be undefined or true", ErrInvalidAuthServerMetadata) 271 + } 272 + if !m.ClientIDMetadataDocumentSupported { 273 + return fmt.Errorf("%w: client_id_metadata_document_supported must be true", ErrInvalidAuthServerMetadata) 274 + } 275 + return nil 276 + } 277 + 278 + // The fields which are included in a PAR request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON. 279 + type PushedAuthRequest struct { 280 + // Client ID, aka client metadata URL 281 + ClientID string `url:"client_id"` 282 + 283 + // Random identifier for this request, generated by client 284 + State string `url:"state"` 285 + 286 + // Client-specified URL that will get redirected to by auth server at end of user auth flow 287 + RedirectURI string `url:"redirect_uri"` 288 + 289 + // Requested auth scopes, as a space-delimited list 290 + Scope string `url:"scope"` 291 + 292 + // Optional account identifier (DID or handle) to help with user account login and/or account switching 293 + LoginHint *string `url:"login_hint,omitempty"` 294 + 295 + // Optional hint to auth server of what expected auth behavior should be. Eg, 'create', 'none', 'consent', 'login', 'select_account' 296 + Prompt *string `url:"prompt,omitempty"` 297 + 298 + // Always "code" 299 + ResponseType string `url:"response_type"` 300 + 301 + // Always "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 302 + ClientAssertionType string `url:"client_assertion_type"` 303 + 304 + // Confidential client signed JWT 305 + ClientAssertion string `url:"client_assertion"` 306 + 307 + // Client-generated PKCE challenge hash, derived from random "verifier" string 308 + CodeChallenge string `url:"code_challenge"` 309 + 310 + // Almost always "S256" 311 + CodeChallengeMethod string `url:"code_challenge_method"` 312 + } 313 + 314 + type PushedAuthResponse struct { 315 + // unique token in URI format, which will be used by the client in the auth flow redirect 316 + RequestURI string `json:"request_uri"` 317 + 318 + // positive integer indicating number of seconds the `request_uri` is valid for. 319 + ExpiresIn int `json:"expires_in"` 320 + } 321 + 322 + // Persisted information about an OAuth Auth Request. 323 + type AuthRequestData struct { 324 + // The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information. 325 + State string `json:"state"` 326 + 327 + // URL of the auth server (eg, PDS or entryway) 328 + AuthServerURL string `json:"authserver_url"` 329 + 330 + // If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response. 331 + AccountDID *syntax.DID `json:"account_did,omitempty"` 332 + 333 + // OAuth scope string (space-separated list) 334 + Scope string `json:"scope"` 335 + 336 + // unique token in URI format, which will be used by the client in the auth flow redirect 337 + RequestURI string `json:"request_uri"` 338 + 339 + // Full token endpoint URL 340 + AuthServerTokenEndpoint string `json:"authserver_token_endpoint"` 341 + 342 + // The secret token/nonce which a code challenge was generated from 343 + PKCEVerifier string `json:"pkce_verifier"` 344 + 345 + // Server-provided DPoP nonce from auth request (PAR) 346 + DPoPAuthServerNonce string `json:"dpop_authserver_nonce"` 347 + 348 + // The secret cryptographic key generated by the client for this specific OAuth session 349 + DPoPPrivateKeyMultibase string `json:"dpop_privatekey_multibase"` 350 + } 351 + 352 + // The fields which are included in an initial token refresh request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON. 353 + type InitialTokenRequest struct { 354 + // Client ID, aka client metadata URL 355 + ClientID string `url:"client_id"` 356 + 357 + // Only used in initial token request. Auth server will validate that this matches the redirect URI used during the auth flow (resulting in the auth code) 358 + RedirectURI string `url:"redirect_uri"` 359 + 360 + // Always `authorization_code` 361 + GrantType string `url:"grant_type"` 362 + 363 + // Refresh token 364 + RefreshToken string `url:"refresh_token"` 365 + 366 + // Authorization Code provided by the Auth Server via callback at the end of the auth request flow 367 + Code string `url:"code"` 368 + 369 + // PKCE verifier string. Only included in initial token request 370 + CodeVerifier string `url:"code_verifier"` 371 + 372 + // For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 373 + ClientAssertionType *string `url:"client_assertion_type"` 374 + 375 + // For confidential clients, the signed client assertion JWT 376 + ClientAssertion *string `url:"client_assertion"` 377 + } 378 + 379 + // The fields which are included in a token refresh request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON. 380 + type RefreshTokenRequest struct { 381 + // Client ID, aka client metadata URL 382 + ClientID string `url:"client_id"` 383 + 384 + // Always `authorization_code` 385 + GrantType string `url:"grant_type"` 386 + 387 + // Refresh token. 388 + RefreshToken string `url:"refresh_token"` 389 + 390 + // For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 391 + ClientAssertionType *string `url:"client_assertion_type"` 392 + 393 + // For confidential clients, the signed client assertion JWT 394 + ClientAssertion *string `url:"client_assertion"` 395 + } 396 + 397 + // Expected response from Auth Server token endpoint, both for initial token request and for refresh requests. 398 + type TokenResponse struct { 399 + Subject string `json:"sub"` 400 + 401 + // Usually expected to be the scopes that the client requested, but technically only a subset may have been approved, or additional scopes granted (?). 402 + Scope string `json:"scope"` 403 + 404 + // Opaque access token, for requests to the resource server. 405 + AccessToken string `json:"access_token"` 406 + 407 + // Refresh token, for doing additional token requests to the auth server. 408 + RefreshToken string `json:"refresh_token"` 409 + }
+24
atproto/auth/oauth/util.go
··· 1 + package oauth 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + ) 8 + 9 + // This is used both for PKCE challenges, and for pseudo-unique nonces to prevent token (JWT) replay. 10 + func secureRandomBase64(sizeBytes uint) string { 11 + buf := make([]byte, sizeBytes) 12 + rand.Read(buf) 13 + return base64.RawURLEncoding.EncodeToString(buf) 14 + } 15 + 16 + // Computes an SHA-256 base64url-encoded challenge string, as used for PKCE. 17 + func S256CodeChallenge(raw string) string { 18 + b := sha256.Sum256([]byte(raw)) 19 + return base64.RawURLEncoding.EncodeToString(b[:]) 20 + } 21 + 22 + func strPtr(raw string) *string { 23 + return &raw 24 + }
+3
go.mod
··· 19 19 github.com/gocql/gocql v1.7.0 20 20 github.com/golang-jwt/jwt v3.2.2+incompatible 21 21 github.com/golang-jwt/jwt/v5 v5.2.2 22 + github.com/google/go-querystring v1.1.0 23 + github.com/gorilla/sessions v1.2.1 22 24 github.com/gorilla/websocket v1.5.1 23 25 github.com/hashicorp/go-retryablehttp v0.7.5 24 26 github.com/hashicorp/golang-lru/arc/v2 v2.0.6 ··· 93 95 github.com/go-redis/redis v6.15.9+incompatible // indirect 94 96 github.com/goccy/go-json v0.10.2 // indirect 95 97 github.com/golang/snappy v0.0.4 // indirect 98 + github.com/gorilla/securecookie v1.1.1 // indirect 96 99 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 97 100 github.com/hashicorp/golang-lru v1.0.2 // indirect 98 101 github.com/ipfs/go-log v1.0.5 // indirect
+7
go.sum
··· 140 140 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 141 141 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 142 142 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 143 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 143 144 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 144 145 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 145 146 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 146 147 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 147 148 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 149 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 150 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 148 151 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 149 152 github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 150 153 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 154 157 github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 155 158 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 156 159 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 160 + github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 161 + github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 162 + github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= 163 + github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 157 164 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 158 165 github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 159 166 github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 h1:6UKoz5ujsI55KNpsJH3UwCq3T8kKbZwNZBNPuTTje8U=