Repository: bluesky-social/indigo

Issue:
goat: improve `goat account plc submit` error messages
The PDS (and PLC directory itself) gives fairly opaque errors if the operation is malformed in some way.

We should be able to use go-didplc to validate the syntax before submission, like `goat plc submit` already does (along with checking that the op is signed)

Discussion:
@bnewbold: (hit this in [#1132](https://github.com/bluesky-social/indigo/pull/1132))

Solution patch:
diff --git a/atproto/auth/oauth/HACKING.md b/atproto/auth/oauth/HACKING.md
new file mode 100644
index 00000000..79a4f740
--- /dev/null
+++ b/atproto/auth/oauth/HACKING.md
@@ -0,0 +1,26 @@
+
+## Package Structure
+
+`oauth.ClientApp`
+- represents an overall application or service; helps establish and manage oauth.ClientSession
+- wraps and manages client metadata, client attestation secret (for confidential clients), request and session storage
+
+`oauth.ClientSession`
+- represents an established user session, wrapping DPoP key, tokens, and other metadata
+- implements client.AuthMethod, for use with ApiClient
+- automates token refresh; for confidential clients requires ref to client secret
+- triggers callback when session data are updated (nonce, tokens)
+
+`oauth.ClientAuthStore`
+- interface for persistent storage systems for auth request and session metadata, including secrets and DPoP private keys
+
+`oauth.Resolver`
+- currently always resolves direct from the network; may add flexible caching or interface abstraction in the future
+
+
+## Implementation Details
+
+- starts DPoP at PAR (specification is flexible about this)
+- requires ES256 (P-256) for DPoP and client attestation private keys; though flexible interface types are used in the API
+- scopes are configured as part of client metadata, and the same for each session
+
diff --git a/atproto/auth/oauth/cmd/oauth-web-demo/README.md b/atproto/auth/oauth/cmd/oauth-web-demo/README.md
new file mode 100644
index 00000000..358fc8ef
--- /dev/null
+++ b/atproto/auth/oauth/cmd/oauth-web-demo/README.md
@@ -0,0 +1,21 @@
+
+OAuth SDK Web App Example
+=========================
+
+This is a minimal Go web app showing how to use the OAuth client SDK.
+
+To get started, generated a `.env` file with the following variables:
+
+- `SESSION_SECRET` (required) is a random string for secure cookies, you can generate one with `openssl rand -hex 16`
+- `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".
+- `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`)
+
+And example file might look like:
+
+```
+SESSION_SECRET=49922828917dc6ac2f2fd2cca78735c3
+CLIENT_SECRET_KEY=z42twLj2gZeJSeRgZ4yPyEb6Yg6nawhU2W8y2ETDDFFyvwym
+CLIENT_HOSTNAME=a9a7c2e14c.ngrok-free.app
+```
+
+Then run the demo (`go run .`) and connect with a web browser.
diff --git a/atproto/auth/oauth/cmd/oauth-web-demo/base.html b/atproto/auth/oauth/cmd/oauth-web-demo/base.html
new file mode 100644
index 00000000..78d3daeb
--- /dev/null
+++ b/atproto/auth/oauth/cmd/oauth-web-demo/base.html
@@ -0,0 +1,36 @@
+
+<!doctype html>                                                                                                                                                                                                     
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="referrer" content="origin-when-cross-origin">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.purple.min.css">
+  <title>atproto OAuth demo (indigo)</title>
+</head>
+<body>
+<header>
+  <hgroup>
+    <h1>atproto OAuth demo (indigo)</h1>
+  </hgroup>
+  <nav>
+    <ul>
+    {{ if . }}
+      <li><span style="font-family: monospace; font-weight: bold;">{{ . }}</span>
+      <li><a href="/bsky/post">Create Post</a>
+      <li><a href="/oauth/refresh">Refresh Tokens</a>
+      <li><a href="/oauth/logout">Logout</a>
+    {{ else }}
+      <li><a href="/oauth/login">Login</a>
+    {{ end }}
+      <li><a href="https://github.com/bluesky-social/indigo/tree/main/atproto/auth/oauth">Code</a>
+    </ul>
+  </nav>
+</header>
+<main>
+  <section class="content">
+    {{ template "content" . }}
+  </section>
+</main>
+</body>
+</html>
diff --git a/atproto/auth/oauth/cmd/oauth-web-demo/home.html b/atproto/auth/oauth/cmd/oauth-web-demo/home.html
new file mode 100644
index 00000000..0ac4755f
--- /dev/null
+++ b/atproto/auth/oauth/cmd/oauth-web-demo/home.html
@@ -0,0 +1,5 @@
+{{ define "content" }}
+<p>This is a minimal web app showing how to use the <a href="https://pkg.go.dev/github.com/bluesky-social/indigo/atproto/auth/oauth">indigo OAuth client SDK</a> to authenticate users. You can read more in the <a href="https://atproto.com/specs/oauth">atproto OAuth Specification</a>
+
+<p>Click "Login" above to get started.
+{{ end }}
diff --git a/atproto/auth/oauth/cmd/oauth-web-demo/login.html b/atproto/auth/oauth/cmd/oauth-web-demo/login.html
new file mode 100644
index 00000000..1c050e58
--- /dev/null
+++ b/atproto/auth/oauth/cmd/oauth-web-demo/login.html
@@ -0,0 +1,13 @@
+{{ define "content" }}
+<article>
+  <h3>Login with atproto</h3>
+  <form method="post">
+      <p>Provide your handle or DID to authorize an existing account with PDS.
+      <br>You can also supply a PDS/entryway URL (eg, <code>https://pds.example.com</code>).</p>
+    <fieldset role="group">
+      <input name="username" id="username" placeholder="handle.example.com" style="font-family: monospace,monospace;" required>
+      <input type="submit" value="Login">
+    </fieldset>
+  </form>
+</article>
+{{ end }}
diff --git a/atproto/auth/oauth/cmd/oauth-web-demo/main.go b/atproto/auth/oauth/cmd/oauth-web-demo/main.go
new file mode 100644
index 00000000..7016cde6
--- /dev/null
+++ b/atproto/auth/oauth/cmd/oauth-web-demo/main.go
@@ -0,0 +1,359 @@
+package main
+
+import (
+	_ "embed"
+	"encoding/json"
+	"fmt"
+	"html/template"
+	"log/slog"
+	"net/http"
+	"os"
+
+	_ "github.com/joho/godotenv/autoload"
+
+	"github.com/bluesky-social/indigo/atproto/auth/oauth"
+	"github.com/bluesky-social/indigo/atproto/crypto"
+	"github.com/bluesky-social/indigo/atproto/identity"
+	"github.com/bluesky-social/indigo/atproto/syntax"
+
+	"github.com/gorilla/sessions"
+	"github.com/urfave/cli/v2"
+)
+
+func main() {
+	app := cli.App{
+		Name:   "oauth-web-demo",
+		Usage:  "atproto OAuth web server demo",
+		Action: runServer,
+		Flags: []cli.Flag{
+			&cli.StringFlag{
+				Name:     "session-secret",
+				Usage:    "random string/token used for session cookie security",
+				Required: true,
+				EnvVars:  []string{"SESSION_SECRET"},
+			},
+			&cli.StringFlag{
+				Name:    "hostname",
+				Usage:   "public host name for this client (if not localhost dev mode)",
+				EnvVars: []string{"CLIENT_HOSTNAME"},
+			},
+			&cli.StringFlag{
+				Name:    "client-secret-key",
+				Usage:   "confidential client secret key. should be P-256 private key in multibase encoding",
+				EnvVars: []string{"CLIENT_SECRET_KEY"},
+			},
+			&cli.StringFlag{
+				Name:    "client-secret-key-id",
+				Usage:   "key id for client-secret-key",
+				Value:   "primary",
+				EnvVars: []string{"CLIENT_SECRET_KEY_ID"},
+			},
+		},
+	}
+	h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
+	slog.SetDefault(slog.New(h))
+	app.RunAndExitOnError()
+}
+
+type Server struct {
+	CookieStore *sessions.CookieStore
+	Dir         identity.Directory
+	OAuth       *oauth.ClientApp
+}
+
+//go:embed "base.html"
+var tmplBaseText string
+
+//go:embed "home.html"
+var tmplHomeText string
+var tmplHome = template.Must(template.Must(template.New("home.html").Parse(tmplBaseText)).Parse(tmplHomeText))
+
+//go:embed "login.html"
+var tmplLoginText string
+var tmplLogin = template.Must(template.Must(template.New("login.html").Parse(tmplBaseText)).Parse(tmplLoginText))
+
+//go:embed "post.html"
+var tmplPostText string
+var tmplPost = template.Must(template.Must(template.New("post.html").Parse(tmplBaseText)).Parse(tmplPostText))
+
+func runServer(cctx *cli.Context) error {
+
+	scopes := []string{"atproto", "transition:generic"}
+	bind := ":8080"
+
+	var config oauth.ClientConfig
+	hostname := cctx.String("hostname")
+	if hostname == "" {
+		config = oauth.NewLocalhostConfig(
+			fmt.Sprintf("http://127.0.0.1%s/oauth/callback", bind),
+			scopes,
+		)
+		slog.Info("configuring localhost OAuth client", "CallbackURL", config.CallbackURL)
+	} else {
+		config = oauth.NewPublicConfig(
+			fmt.Sprintf("https://%s/oauth/client-metadata.json", hostname),
+			fmt.Sprintf("https://%s/oauth/callback", hostname),
+			scopes,
+		)
+	}
+
+	// If a client secret key is provided (as a multibase string), turn this in to a confidential client
+	if cctx.String("client-secret-key") != "" && hostname != "" {
+		priv, err := crypto.ParsePrivateMultibase(cctx.String("client-secret-key"))
+		if err != nil {
+			return err
+		}
+		if err := config.SetClientSecret(priv, cctx.String("client-secret-key-id")); err != nil {
+			return err
+		}
+		slog.Info("configuring confidential OAuth client")
+	}
+
+	oauthClient := oauth.NewClientApp(&config, oauth.NewMemStore())
+
+	srv := Server{
+		CookieStore: sessions.NewCookieStore([]byte(cctx.String("session-secret"))),
+		Dir:         identity.DefaultDirectory(),
+		OAuth:       oauthClient,
+	}
+
+	http.HandleFunc("GET /", srv.Homepage)
+	http.HandleFunc("GET /oauth/client-metadata.json", srv.ClientMetadata)
+	http.HandleFunc("GET /oauth/jwks.json", srv.JWKS)
+	http.HandleFunc("GET /oauth/login", srv.OAuthLogin)
+	http.HandleFunc("POST /oauth/login", srv.OAuthLogin)
+	http.HandleFunc("GET /oauth/callback", srv.OAuthCallback)
+	http.HandleFunc("GET /oauth/refresh", srv.OAuthRefresh)
+	http.HandleFunc("GET /oauth/logout", srv.OAuthLogout)
+	http.HandleFunc("GET /bsky/post", srv.Post)
+	http.HandleFunc("POST /bsky/post", srv.Post)
+
+	slog.Info("starting http server", "bind", bind)
+	if err := http.ListenAndServe(bind, nil); err != nil {
+		slog.Error("http shutdown", "err", err)
+	}
+	return nil
+}
+
+func (s *Server) currentSessionDID(r *http.Request) (*syntax.DID, string) {
+	sess, _ := s.CookieStore.Get(r, "oauth-demo")
+	accountDID, ok := sess.Values["account_did"].(string)
+	if !ok || accountDID == "" {
+		return nil, ""
+	}
+	did, err := syntax.ParseDID(accountDID)
+	if err != nil {
+		return nil, ""
+	}
+	sessionID, ok := sess.Values["session_id"].(string)
+	if !ok || sessionID == "" {
+		return nil, ""
+	}
+
+	return &did, sessionID
+}
+
+func strPtr(raw string) *string {
+	return &raw
+}
+
+func (s *Server) ClientMetadata(w http.ResponseWriter, r *http.Request) {
+	slog.Info("client metadata request", "url", r.URL, "host", r.Host)
+
+	meta := s.OAuth.Config.ClientMetadata()
+	if s.OAuth.Config.IsConfidential() {
+		meta.JWKSURI = strPtr(fmt.Sprintf("https://%s/oauth/jwks.json", r.Host))
+	}
+	meta.ClientName = strPtr("indigo atp-oauth-demo")
+	meta.ClientURI = strPtr(fmt.Sprintf("https://%s", r.Host))
+
+	// internal consistency check
+	if err := meta.Validate(s.OAuth.Config.ClientID); err != nil {
+		slog.Error("validating client metadata", "err", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(meta); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) JWKS(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	body := s.OAuth.Config.PublicJWKS()
+	if err := json.NewEncoder(w).Encode(body); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) Homepage(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// attempts to load Session to display links
+	did, sessionID := s.currentSessionDID(r)
+	if did == nil {
+		tmplHome.Execute(w, nil)
+		return
+	}
+
+	_, err := s.OAuth.ResumeSession(ctx, *did, sessionID)
+	if err != nil {
+		tmplHome.Execute(w, nil)
+		return
+	}
+	tmplHome.Execute(w, did)
+}
+
+func (s *Server) OAuthLogin(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	if r.Method != "POST" {
+		tmplLogin.Execute(w, nil)
+		return
+	}
+
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, fmt.Errorf("parsing form data: %w", err).Error(), http.StatusBadRequest)
+		return
+	}
+
+	username := r.PostFormValue("username")
+
+	slog.Info("OAuthLogin", "client_id", s.OAuth.Config.ClientID, "callback_url", s.OAuth.Config.CallbackURL)
+
+	redirectURL, err := s.OAuth.StartAuthFlow(ctx, username)
+	if err != nil {
+		http.Error(w, fmt.Errorf("OAuth login failed: %w", err).Error(), http.StatusBadRequest)
+		return
+	}
+
+	http.Redirect(w, r, redirectURL, http.StatusFound)
+	return
+}
+
+func (s *Server) OAuthCallback(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	params := r.URL.Query()
+	slog.Info("received callback", "params", params)
+
+	sessData, err := s.OAuth.ProcessCallback(ctx, r.URL.Query())
+	if err != nil {
+		http.Error(w, fmt.Errorf("processing OAuth callback: %w", err).Error(), http.StatusBadRequest)
+		return
+	}
+
+	// create signed cookie session, indicating account DID
+	sess, _ := s.CookieStore.Get(r, "oauth-demo")
+	sess.Values["account_did"] = sessData.AccountDID.String()
+	sess.Values["session_id"] = sessData.SessionID
+	if err := sess.Save(r, w); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	slog.Info("login successful", "did", sessData.AccountDID.String())
+	http.Redirect(w, r, "/bsky/post", http.StatusFound)
+}
+
+func (s *Server) OAuthRefresh(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	did, sessionID := s.currentSessionDID(r)
+	if did == nil {
+		// TODO: supposed to set a WWW header; and could redirect?
+		http.Error(w, "not authenticated", http.StatusUnauthorized)
+		return
+	}
+
+	oauthSess, err := s.OAuth.ResumeSession(ctx, *did, sessionID)
+	if err != nil {
+		http.Error(w, "not authenticated", http.StatusUnauthorized)
+		return
+	}
+
+	_, err = oauthSess.RefreshTokens(ctx)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	s.OAuth.Store.SaveSession(ctx, *oauthSess.Data)
+	slog.Info("refreshed tokens")
+	http.Redirect(w, r, "/", http.StatusFound)
+}
+
+func (s *Server) OAuthLogout(w http.ResponseWriter, r *http.Request) {
+
+	// delete session from auth store
+	did, sessionID := s.currentSessionDID(r)
+	if did != nil {
+		if err := s.OAuth.Store.DeleteSession(r.Context(), *did, sessionID); err != nil {
+			slog.Error("failed to delete session", "did", did, "err", err)
+		}
+	}
+
+	// wipe all secure cookie session data
+	sess, _ := s.CookieStore.Get(r, "oauth-demo")
+	sess.Values = make(map[any]any)
+	err := sess.Save(r, w)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	slog.Info("logged out")
+	http.Redirect(w, r, "/", http.StatusFound)
+}
+
+func (s *Server) Post(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	slog.Info("in post handler")
+
+	did, sessionID := s.currentSessionDID(r)
+	if did == nil {
+		// TODO: supposed to set a WWW header; and could redirect?
+		http.Error(w, "not authenticated", http.StatusUnauthorized)
+		return
+	}
+
+	if r.Method != "POST" {
+		tmplPost.Execute(w, did)
+		return
+	}
+
+	oauthSess, err := s.OAuth.ResumeSession(ctx, *did, sessionID)
+	if err != nil {
+		http.Error(w, "not authenticated", http.StatusUnauthorized)
+		return
+	}
+	c := oauthSess.APIClient()
+
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, fmt.Errorf("parsing form data: %w", err).Error(), http.StatusBadRequest)
+		return
+	}
+	text := r.PostFormValue("post_text")
+
+	body := map[string]any{
+		"repo":       c.AccountDID.String(),
+		"collection": "app.bsky.feed.post",
+		"record": map[string]any{
+			"$type":     "app.bsky.feed.post",
+			"text":      text,
+			"createdAt": syntax.DatetimeNow(),
+		},
+	}
+
+	slog.Info("attempting post...", "text", text)
+	if err := c.Post(ctx, "com.atproto.repo.createRecord", body, nil); err != nil {
+		http.Error(w, fmt.Errorf("posting failed: %w", err).Error(), http.StatusBadRequest)
+		return
+	}
+
+	http.Redirect(w, r, "/bsky/post", http.StatusFound)
+}
diff --git a/atproto/auth/oauth/cmd/oauth-web-demo/post.html b/atproto/auth/oauth/cmd/oauth-web-demo/post.html
new file mode 100644
index 00000000..27c15a05
--- /dev/null
+++ b/atproto/auth/oauth/cmd/oauth-web-demo/post.html
@@ -0,0 +1,6 @@
+{{ define "content" }}
+  <form method="post">
+    <textarea name="post_text" placeholder="What's up?" id="post_text" required></textarea>
+    <input type="submit" value="Poast!">
+  </form>
+{{ end }}
diff --git a/atproto/auth/oauth/doc.go b/atproto/auth/oauth/doc.go
new file mode 100644
index 00000000..1e0c1ae8
--- /dev/null
+++ b/atproto/auth/oauth/doc.go
@@ -0,0 +1,145 @@
+/*
+OAuth implementation for atproto, currently focused on clients.
+
+Feature set includes:
+
+  - client and server metadata resolution
+  - PKCE: computing and verifying challenges
+  - DPoP client implementation: JWT signing and nonces for requests to Auth Server and Resource Server
+  - PAR client submission
+  - both public and confidential clients, with support for signed client attestations in the later case
+
+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.
+
+This package does not contain supporting code for atproto permissions or permission sets. It treats scopes as simple strings.
+
+# Quickstart
+
+Create a single [ClientApp] instance during service setup that will be used (concurrently) across all users and sessions:
+
+	config := oauth.NewPublicConfig(
+		"https://app.example.com/client-metadata.json",
+		"https://app.example.com/oauth/callback",
+		[]string{"atproto", "transition:generic"},
+	)
+
+	// clients are "public" by default, but if they have secure access to a secret attestation key can be "confidential"
+	if CLIENT_SECRET_KEY != "" {
+		priv, err := crypto.ParsePrivateMultibase(CLIENT_SECRET_KEY)
+		if err != nil {
+			return err
+		}
+		if err := config.SetClientSecret(priv, "example1"); err != nil {
+			return err
+		}
+	}
+
+	oauthApp := oauth.NewClientApp(&config, oauth.NewMemStore())
+
+For a real service, you would want to use a database or other peristant implementation of the [ClientAuthStore] interface instead of [MemStore]. Otherwise all user sessions are dropped every time the process restarts.
+
+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:
+
+	http.HandleFunc("GET /client-metadata.json", HandleClientMetadata)
+
+	func HandleClientMetadata(w http.ResponseWriter, r *http.Request) {
+		doc := oauthApp.Config.ClientMetadata()
+
+		// if this is is a confidential client, need to set doc.JWKSURI, and implement a handler
+
+		w.Header().Set("Content-Type", "application/json")
+		if err := json.NewEncoder(w).Encode(doc); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	}
+
+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 [ClientApp.StartAuthFlow] method will resolve the identifier, send an auth request (PAR) to the server, persist request metadata in the [ClientAuthStore], and return a redirect URL for the user to visit (usually the PDS):
+
+	http.HandleFunc("GET /oauth/login", HandleLogin)
+
+	func HandleLogin(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+
+		// parse login identifier from the request
+		identifier := "..."
+
+		redirectURL, err := oauthApp.StartAuthFlow(ctx, identifier)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+	}
+
+The service then waits for a callback request on the configured endpoint. The [ClientApp.ProcessCallback] method will load the earlier request metadata from the [ClientAuthStore], 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.
+
+	http.HandleFunc("GET /oauth/callback", HandleOAuthCallback)
+
+	func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+
+		sessData, err := oauthApp.ProcessCallback(ctx, r.URL.Query())
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+
+		// web services might record the DID and session ID in a secure session cookie
+		_ = sessData.AccountDID
+		_ = sessData.SessionID
+
+		// the returned scopes might not include all of those requested
+		_ = sessData.Scopes
+
+		http.Redirect(w, r, "/app", http.StatusFound)
+	}
+
+Sessions can be resumed and used to make authenticated API calls to the user's host:
+
+	// web services might use a secure session cookie to determine user's DID for a request
+	did := syntax.DID("did:plc:abc123")
+	sessionID := "xyz"
+
+	sess, err := oauthApp.ResumeSession(ctx, did, sessionID)
+	if err != nil {
+		return err
+	}
+
+	c := sess.APIClient()
+
+	body := map[string]any{
+		"repo":       *c.AccountDID,
+		"collection": "app.bsky.feed.post",
+		"record": map[string]any{
+		    "$type":     "app.bsky.feed.post",
+		    "text":      "Hello World via OAuth!",
+		    "createdAt": syntax.DatetimeNow(),
+		},
+	}
+
+	if err := c.Post(ctx, "com.atproto.repo.createRecord", body, nil); err != nil {
+		return err
+	}
+
+The [ClientSession] will handle nonce updates and token refreshes, and persist the results in the [ClientAuthStore].
+
+To log out a user, delete their session from the [ClientAuthStore]:
+
+	if err := oauthApp.Store.DeleteSession(r.Context(), did, sessionID); err != nil {
+		return err
+	}
+
+# Authorization-only Situations
+
+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).
+
+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.
+
+In these scenarios, applications could use an implementation of [ClientAuthStore] which does not actually persist the session data when [ClientAuthStore.SaveSession] is called. Or, the application could immediately call [ClientAuthStore.DeleteSession] after [ClientApp.ProcessCallback] returns.
+
+# Multiple Sessions Per Account
+
+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.
+
+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 [ClientAuthStore] interface could ignore the session ID. Or the [ClientApp] could be configured with an ephemeral [ClientAuthStore] (to support auth flows), and managed the session data returned by [ClientApp.ProcessCallback] using separate session storage logic.
+*/
+package oauth
diff --git a/atproto/auth/oauth/jwt_signing.go b/atproto/auth/oauth/jwt_signing.go
new file mode 100644
index 00000000..6c3ffe13
--- /dev/null
+++ b/atproto/auth/oauth/jwt_signing.go
@@ -0,0 +1,87 @@
+package oauth
+
+import (
+	"crypto"
+	"fmt"
+
+	atcrypto "github.com/bluesky-social/indigo/atproto/crypto"
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// NOTE: this file is copied from indigo:atproto/auth/jwt_signing.go, with the K-256 (ES256) support removed
+
+var (
+	signingMethodES256 *signingMethodAtproto
+	supportedAlgs      []string
+)
+
+// Implementation of jwt.SigningMethod for the `atproto/crypto` types.
+type signingMethodAtproto struct {
+	alg      string
+	hash     crypto.Hash
+	toOutSig toOutSig
+	sigLen   int
+}
+
+type toOutSig func(sig []byte) []byte
+
+func init() {
+	// tells JWT library to serialize 'aud' as regular string, not array of strings (when signing)
+	jwt.MarshalSingleStringAsArray = false
+
+	signingMethodES256 = &signingMethodAtproto{
+		alg:      "ES256",
+		hash:     crypto.SHA256,
+		toOutSig: toES256,
+		sigLen:   64,
+	}
+	jwt.RegisterSigningMethod(signingMethodES256.Alg(), func() jwt.SigningMethod {
+		return signingMethodES256
+	})
+	supportedAlgs = []string{signingMethodES256.Alg()}
+}
+
+func (sm *signingMethodAtproto) Verify(signingString string, sig []byte, key interface{}) error {
+	pub, ok := key.(atcrypto.PublicKey)
+	if !ok {
+		return jwt.ErrInvalidKeyType
+	}
+
+	if !sm.hash.Available() {
+		return jwt.ErrHashUnavailable
+	}
+
+	if len(sig) != sm.sigLen {
+		return jwt.ErrTokenSignatureInvalid
+	}
+
+	// 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
+	return pub.HashAndVerifyLenient([]byte(signingString), sig)
+}
+
+func (sm *signingMethodAtproto) Sign(signingString string, key interface{}) ([]byte, error) {
+	priv, ok := key.(atcrypto.PrivateKey)
+	if !ok {
+		return nil, jwt.ErrInvalidKeyType
+	}
+
+	return priv.HashAndSign([]byte(signingString))
+}
+
+func (sm *signingMethodAtproto) Alg() string {
+	return sm.alg
+}
+
+func toES256(sig []byte) []byte {
+	return sig[:64]
+}
+
+func keySigningMethod(key atcrypto.PrivateKey) (jwt.SigningMethod, error) {
+	switch key.(type) {
+	case *atcrypto.PrivateKeyP256:
+		return signingMethodES256, nil
+	case *atcrypto.PrivateKeyK256:
+		return nil, fmt.Errorf("only P-256 (ES256) private keys supported for atproto OAuth")
+	}
+	return nil, fmt.Errorf("unknown key type: %T", key)
+}
diff --git a/atproto/auth/oauth/memstore.go b/atproto/auth/oauth/memstore.go
new file mode 100644
index 00000000..f65be39e
--- /dev/null
+++ b/atproto/auth/oauth/memstore.go
@@ -0,0 +1,87 @@
+package oauth
+
+import (
+	"context"
+	"fmt"
+	"sync"
+
+	"github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+// Simple in-memory implementation of [ClientAuthStore], for use in development and demos.
+//
+// This is not appropriate even casual real-world use: all users will be logged-out every time the process is restarted.
+type MemStore struct {
+	requests map[string]AuthRequestData
+	sessions map[string]ClientSessionData
+
+	lk sync.Mutex
+}
+
+var _ ClientAuthStore = &MemStore{}
+
+func NewMemStore() *MemStore {
+	return &MemStore{
+		requests: make(map[string]AuthRequestData),
+		sessions: make(map[string]ClientSessionData),
+	}
+}
+
+func memKey(did syntax.DID, sessionID string) string {
+	return fmt.Sprintf("%s/%s", did, sessionID)
+}
+
+func (m *MemStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*ClientSessionData, error) {
+	m.lk.Lock()
+	defer m.lk.Unlock()
+
+	sess, ok := m.sessions[memKey(did, sessionID)]
+	if !ok {
+		return nil, fmt.Errorf("session not found: %s", did)
+	}
+	return &sess, nil
+}
+
+func (m *MemStore) SaveSession(ctx context.Context, sess ClientSessionData) error {
+	m.lk.Lock()
+	defer m.lk.Unlock()
+
+	m.sessions[memKey(sess.AccountDID, sess.SessionID)] = sess
+	return nil
+}
+
+func (m *MemStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
+	m.lk.Lock()
+	defer m.lk.Unlock()
+
+	delete(m.sessions, memKey(did, sessionID))
+	return nil
+}
+
+func (m *MemStore) GetAuthRequestInfo(ctx context.Context, state string) (*AuthRequestData, error) {
+	m.lk.Lock()
+	defer m.lk.Unlock()
+
+	req, ok := m.requests[state]
+	if !ok {
+		return nil, fmt.Errorf("request info not found: %s", state)
+	}
+	// TODO: delete? should only ever fetch once
+	return &req, nil
+}
+
+func (m *MemStore) SaveAuthRequestInfo(ctx context.Context, info AuthRequestData) error {
+	m.lk.Lock()
+	defer m.lk.Unlock()
+
+	m.requests[info.State] = info
+	return nil
+}
+
+func (m *MemStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
+	m.lk.Lock()
+	defer m.lk.Unlock()
+
+	delete(m.requests, state)
+	return nil
+}
diff --git a/atproto/auth/oauth/oauth.go b/atproto/auth/oauth/oauth.go
new file mode 100644
index 00000000..ddd02953
--- /dev/null
+++ b/atproto/auth/oauth/oauth.go
@@ -0,0 +1,660 @@
+package oauth
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/bluesky-social/indigo/atproto/crypto"
+	"github.com/bluesky-social/indigo/atproto/identity"
+	"github.com/bluesky-social/indigo/atproto/syntax"
+
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/google/go-querystring/query"
+)
+
+var jwtExpirationDuration = 30 * time.Second
+
+// 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.
+type ClientApp struct {
+	Client   *http.Client
+	Resolver *Resolver
+	Dir      identity.Directory
+	Config   *ClientConfig
+	Store    ClientAuthStore
+}
+
+// App-level client configuration data.
+//
+// Not to be confused with the [ClientMetadata] struct type, which represents a full client metadata JSON document.
+type ClientConfig struct {
+	// Full client identifier, which should be an HTTP URL
+	ClientID string
+	// Fully qualified callback URL
+	CallbackURL string
+	// Set of OAuth scope strings, which will be both declared in client metadata document and requested for every session. Must include "atproto".
+	Scopes    []string
+	UserAgent string
+
+	// 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.
+	PrivateKey crypto.PrivateKey
+
+	// ID for current client assertion key (should be provided if PrivateKey is)
+	KeyID *string
+}
+
+// Constructs a [ClientApp] based on configuration.
+func NewClientApp(config *ClientConfig, store ClientAuthStore) *ClientApp {
+	app := &ClientApp{
+		Client:   http.DefaultClient,
+		Resolver: NewResolver(),
+		Dir:      identity.DefaultDirectory(),
+		Config:   config,
+		Store:    store,
+	}
+	if config.UserAgent != "" {
+		app.Resolver.UserAgent = config.UserAgent
+
+		// unpack DefaultDirectory nested type and insert UserAgent (and log failure in case default types change)
+		dirAgent := false
+		cdir, ok := app.Dir.(*identity.CacheDirectory)
+		if ok {
+			bdir, ok := cdir.Inner.(*identity.BaseDirectory)
+			if ok {
+				dirAgent = true
+				bdir.UserAgent = config.UserAgent
+			}
+		}
+		if !dirAgent {
+			slog.Info("OAuth ClientApp identity directory User-Agent not configured")
+		}
+	}
+	return app
+}
+
+// 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].
+//
+// The "scopes" array must include "atproto".
+func NewPublicConfig(clientID, callbackURL string, scopes []string) ClientConfig {
+	c := ClientConfig{
+		ClientID:    clientID,
+		CallbackURL: callbackURL,
+		UserAgent:   "indigo-sdk",
+		Scopes:      scopes,
+	}
+	return c
+}
+
+// Creates a basic [ClientConfig] for use with localhost developmnet. Such a client is always public (non-confidential).
+//
+// The "scopes" array must include "atproto".
+func NewLocalhostConfig(callbackURL string, scopes []string) ClientConfig {
+	params := make(url.Values)
+	params.Set("redirect_uri", callbackURL)
+	params.Set("scope", scopeStr(scopes))
+	c := ClientConfig{
+		ClientID:    fmt.Sprintf("http://localhost?%s", params.Encode()),
+		CallbackURL: callbackURL,
+		UserAgent:   "indigo-sdk",
+		Scopes:      scopes,
+	}
+	return c
+}
+
+// Whether this is a "confidential" OAuth client (with configured client attestation key), versus "public" client.
+func (config *ClientConfig) IsConfidential() bool {
+	return config.PrivateKey != nil && config.KeyID != nil
+}
+
+func (config *ClientConfig) SetClientSecret(priv crypto.PrivateKey, keyID string) error {
+	switch priv.(type) {
+	case *crypto.PrivateKeyP256:
+		// pass
+	case *crypto.PrivateKeyK256:
+		return fmt.Errorf("only P-256 (ES256) private keys supported for atproto OAuth")
+	default:
+		return fmt.Errorf("unknown private key type: %T", priv)
+	}
+	config.PrivateKey = priv
+	config.KeyID = &keyID
+	return nil
+}
+
+// Returns a "JWKS" representation of public keys for the client. This can be returned as JSON, as part of client metadata.
+//
+// If the client does not have any keys (eg, public client), returns an empty set.
+func (config *ClientConfig) PublicJWKS() JWKS {
+
+	jwks := JWKS{Keys: []crypto.JWK{}}
+
+	// public client with no keys
+	if config.PrivateKey == nil || config.KeyID == nil {
+		return jwks
+	}
+
+	pub, err := config.PrivateKey.PublicKey()
+	if err != nil {
+		return jwks
+	}
+	jwk, err := pub.JWK()
+	if err != nil {
+		return jwks
+	}
+	jwk.KeyID = config.KeyID
+
+	jwks.Keys = []crypto.JWK{*jwk}
+	return jwks
+}
+
+// helper to turn a list of scope strings in to a single space-separated scope string
+func scopeStr(scopes []string) string {
+	return strings.Join(scopes, " ")
+}
+
+// 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.
+//
+// NOTE: confidential clients currently must provide JWKSURI after the fact
+func (config *ClientConfig) ClientMetadata() ClientMetadata {
+	m := ClientMetadata{
+		ClientID:                config.ClientID,
+		ApplicationType:         strPtr("web"),
+		GrantTypes:              []string{"authorization_code", "refresh_token"},
+		Scope:                   scopeStr(config.Scopes),
+		ResponseTypes:           []string{"code"},
+		RedirectURIs:            []string{config.CallbackURL},
+		DPoPBoundAccessTokens:   true,
+		TokenEndpointAuthMethod: "none",
+	}
+	if config.IsConfidential() {
+		m.TokenEndpointAuthMethod = "private_key_jwt"
+		// NOTE: the key type is always ES256
+		m.TokenEndpointAuthSigningAlg = strPtr("ES256")
+
+		// TODO: need to include 'use' or 'key_ops' for JWKS in the client metadata doc?
+		//jwks := config.PublicJWKS()
+		//m.JWKS = &jwks
+	}
+	return m
+}
+
+// High-level helper for fetching session data from store, based on account DID and session identifier.
+func (app *ClientApp) ResumeSession(ctx context.Context, did syntax.DID, sessionID string) (*ClientSession, error) {
+
+	sd, err := app.Store.GetSession(ctx, did, sessionID)
+	if err != nil {
+		return nil, err
+	}
+
+	sess := ClientSession{
+		Client: app.Client,
+		Config: app.Config,
+		Data:   sd,
+	}
+
+	// configure callback for updating session data
+	if app.Store != nil {
+		sess.PersistSessionCallback = func(ctx context.Context, data *ClientSessionData) {
+			slog.Debug("storing updated session data", "did", data.AccountDID, "session_id", data.SessionID)
+			err := app.Store.SaveSession(ctx, *data)
+			if err != nil {
+				slog.Error("failed to store updated session data", "did", data.AccountDID, "session_id", data.SessionID, "err", err)
+			}
+		}
+	}
+
+	// TODO: refactor this in to ClientAuthStore layer?
+	priv, err := crypto.ParsePrivateMultibase(sd.DPoPPrivateKeyMultibase)
+	if err != nil {
+		return nil, err
+	}
+	sess.DPoPPrivateKey = priv
+	return &sess, nil
+}
+
+type clientAssertionClaims struct {
+	jwt.RegisteredClaims
+
+	HTTPMethod      string  `json:"htm"`
+	TargetURI       string  `json:"hti"`
+	AccessTokenHash *string `json:"ath,omitempty"`
+	Nonce           *string `json:"nonce,omitempty"`
+}
+
+type dpopClaims struct {
+	jwt.RegisteredClaims
+
+	HTTPMethod      string  `json:"htm"`
+	TargetURI       string  `json:"htu"`
+	AccessTokenHash *string `json:"ath,omitempty"`
+	Nonce           *string `json:"nonce,omitempty"`
+}
+
+// Low-level helper to generate and sign an OAuth confidential client assertion token (JWT).
+func (cfg *ClientConfig) NewClientAssertion(authURL string) (string, error) {
+	if !cfg.IsConfidential() {
+		return "", fmt.Errorf("non-confidential client")
+	}
+	claims := clientAssertionClaims{
+		RegisteredClaims: jwt.RegisteredClaims{
+			Issuer:   cfg.ClientID,
+			Subject:  cfg.ClientID,
+			Audience: []string{authURL},
+			ID:       secureRandomBase64(16),
+			IssuedAt: jwt.NewNumericDate(time.Now()),
+		},
+	}
+
+	signingMethod, err := keySigningMethod(cfg.PrivateKey)
+	if err != nil {
+		return "", err
+	}
+
+	token := jwt.NewWithClaims(signingMethod, claims)
+	token.Header["kid"] = cfg.KeyID
+	return token.SignedString(cfg.PrivateKey)
+}
+
+// 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').
+//
+// 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.
+func NewAuthDPoP(httpMethod, url, dpopNonce string, privKey crypto.PrivateKey) (string, error) {
+
+	claims := dpopClaims{
+		HTTPMethod: httpMethod,
+		TargetURI:  url,
+		RegisteredClaims: jwt.RegisteredClaims{
+			ID:        secureRandomBase64(16),
+			IssuedAt:  jwt.NewNumericDate(time.Now()),
+			ExpiresAt: jwt.NewNumericDate(time.Now().Add(jwtExpirationDuration)),
+		},
+	}
+	if dpopNonce != "" {
+		claims.Nonce = &dpopNonce
+	}
+
+	keyMethod, err := keySigningMethod(privKey)
+	if err != nil {
+		return "", err
+	}
+
+	// TODO: parse/cache this public JWK, for efficiency
+	pub, err := privKey.PublicKey()
+	if err != nil {
+		return "", err
+	}
+	pubJWK, err := pub.JWK()
+	if err != nil {
+		return "", err
+	}
+
+	token := jwt.NewWithClaims(keyMethod, claims)
+	token.Header["typ"] = "dpop+jwt"
+	token.Header["jwk"] = pubJWK
+	return token.SignedString(privKey)
+}
+
+// attempts to read an HTTP response body as JSON, and determine an error reason. always closes the response body
+func parseAuthErrorReason(resp *http.Response, reqType string) string {
+	defer resp.Body.Close()
+	var errResp map[string]any
+	if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
+		slog.Warn("auth server request failed", "request", reqType, "statusCode", resp.StatusCode, "err", err)
+		return "unknown"
+	}
+	slog.Warn("auth server request failed", "request", reqType, "statusCode", resp.StatusCode, "body", errResp)
+	return fmt.Sprintf("%s", errResp["error"])
+}
+
+// Low-level helper to send PAR request to auth server, which involves starting PKCE and DPoP.
+func (app *ClientApp) SendAuthRequest(ctx context.Context, authMeta *AuthServerMetadata, scope, loginHint string) (*AuthRequestData, error) {
+
+	parURL := authMeta.PushedAuthorizationRequestEndpoint
+	state := secureRandomBase64(16)
+	pkceVerifier := secureRandomBase64(48)
+
+	// generate PKCE code challenge for use in PAR request
+	codeChallenge := S256CodeChallenge(pkceVerifier)
+
+	slog.Debug("preparing PAR", "client_id", app.Config.ClientID, "callback_url", app.Config.CallbackURL)
+	body := PushedAuthRequest{
+		ClientID:            app.Config.ClientID,
+		State:               state,
+		RedirectURI:         app.Config.CallbackURL,
+		Scope:               scope,
+		ResponseType:        "code",
+		CodeChallenge:       codeChallenge,
+		CodeChallengeMethod: "S256",
+	}
+
+	if app.Config.IsConfidential() {
+		// self-signed JWT using private key in client metadata (confidential client)
+		assertionJWT, err := app.Config.NewClientAssertion(authMeta.Issuer)
+		if err != nil {
+			return nil, err
+		}
+		body.ClientAssertionType = ClientAssertionJWTBearer
+		body.ClientAssertion = assertionJWT
+	}
+
+	if loginHint != "" {
+		body.LoginHint = &loginHint
+	}
+	vals, err := query.Values(body)
+	if err != nil {
+		return nil, err
+	}
+	bodyBytes := []byte(vals.Encode())
+
+	// when starting a new session, we don't know the DPoP nonce yet
+	dpopServerNonce := ""
+
+	// create new key for the session
+	dpopPrivKey, err := crypto.GeneratePrivateKeyP256()
+	if err != nil {
+		return nil, err
+	}
+
+	slog.Debug("sending auth request", "scope", scope, "state", state, "redirectURI", app.Config.CallbackURL)
+
+	var resp *http.Response
+	for range 2 {
+		dpopJWT, err := NewAuthDPoP("POST", parURL, dpopServerNonce, dpopPrivKey)
+		if err != nil {
+			return nil, err
+		}
+
+		req, err := http.NewRequestWithContext(ctx, "POST", parURL, bytes.NewBuffer(bodyBytes))
+		if err != nil {
+			return nil, err
+		}
+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+		req.Header.Set("DPoP", dpopJWT)
+
+		resp, err = app.Client.Do(req)
+		if err != nil {
+			return nil, err
+		}
+
+		// update DPoP Nonce
+		dpopServerNonce = resp.Header.Get("DPoP-Nonce")
+
+		// check for an error condition caused by an out of date DPoP nonce
+		// 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
+		if resp.StatusCode == http.StatusBadRequest && dpopServerNonce != "" {
+			// parseAuthErrorReason() always closes resp.Body
+			reason := parseAuthErrorReason(resp, "PAR")
+			if reason == "use_dpop_nonce" {
+				// already updated nonce value above; loop around and try again
+				continue
+			}
+			return nil, fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, reason)
+		}
+
+		// otherwise process result
+		break
+	}
+
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+		reason := parseAuthErrorReason(resp, "PAR")
+		return nil, fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, reason)
+	}
+
+	var parResp PushedAuthResponse
+	if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil {
+		return nil, fmt.Errorf("auth request (PAR) response failed to decode: %w", err)
+	}
+
+	parInfo := AuthRequestData{
+		State:                   state,
+		AuthServerURL:           authMeta.Issuer,
+		Scope:                   scope,
+		PKCEVerifier:            pkceVerifier,
+		RequestURI:              parResp.RequestURI,
+		AuthServerTokenEndpoint: authMeta.TokenEndpoint,
+		DPoPAuthServerNonce:     dpopServerNonce,
+		DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(),
+	}
+
+	return &parInfo, nil
+}
+
+// Lower-level helper. This is usually invoked as part of [ClientApp.ProcessCallback].
+func (app *ClientApp) SendInitialTokenRequest(ctx context.Context, authCode string, info AuthRequestData) (*TokenResponse, error) {
+
+	body := InitialTokenRequest{
+		ClientID:     app.Config.ClientID,
+		RedirectURI:  app.Config.CallbackURL,
+		GrantType:    "authorization_code",
+		Code:         authCode,
+		CodeVerifier: info.PKCEVerifier,
+	}
+
+	if app.Config.IsConfidential() {
+		clientAssertion, err := app.Config.NewClientAssertion(info.AuthServerURL)
+		if err != nil {
+			return nil, err
+		}
+		body.ClientAssertionType = &ClientAssertionJWTBearer
+		body.ClientAssertion = &clientAssertion
+	}
+
+	dpopPrivKey, err := crypto.ParsePrivateMultibase(info.DPoPPrivateKeyMultibase)
+	if err != nil {
+		return nil, err
+	}
+
+	vals, err := query.Values(body)
+	if err != nil {
+		return nil, err
+	}
+	bodyBytes := []byte(vals.Encode())
+
+	dpopServerNonce := info.DPoPAuthServerNonce
+
+	var resp *http.Response
+	for range 2 {
+		dpopJWT, err := NewAuthDPoP("POST", info.AuthServerTokenEndpoint, dpopServerNonce, dpopPrivKey)
+		if err != nil {
+			return nil, err
+		}
+
+		req, err := http.NewRequestWithContext(ctx, "POST", info.AuthServerTokenEndpoint, bytes.NewBuffer(bodyBytes))
+		if err != nil {
+			return nil, err
+		}
+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+		req.Header.Set("DPoP", dpopJWT)
+
+		resp, err = app.Client.Do(req)
+		if err != nil {
+			return nil, err
+		}
+
+		// check if a nonce was provided
+		dpopNonceHdr := resp.Header.Get("DPoP-Nonce")
+		if dpopNonceHdr != "" && dpopNonceHdr != dpopServerNonce {
+			dpopServerNonce = dpopNonceHdr
+		}
+
+		// check for an error condition caused by an out of date DPoP nonce
+		// 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
+		if resp.StatusCode == http.StatusBadRequest && dpopNonceHdr != "" {
+			// parseAuthErrorReason() always closes resp.Body
+			reason := parseAuthErrorReason(resp, "initial-token")
+			if reason == "use_dpop_nonce" {
+				// already updated nonce value above; loop around and try again
+				continue
+			}
+			return nil, fmt.Errorf("initial token request failed (HTTP %d): %s", resp.StatusCode, reason)
+		}
+
+		// otherwise process result
+		break
+	}
+
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		reason := parseAuthErrorReason(resp, "initial-token")
+		return nil, fmt.Errorf("initial token request failed (HTTP %d): %s", resp.StatusCode, reason)
+	}
+
+	var tokenResp TokenResponse
+	if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
+		return nil, fmt.Errorf("token response failed to decode: %w", err)
+	}
+
+	return &tokenResp, nil
+}
+
+// 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.
+//
+// The `identifier` argument can be an atproto account identifier (handle or DID), or can be a URL to the account's auth server.
+//
+// The returned sting will be a web URL that the user should be redirected to (in browser) to approve the auth flow.
+func (app *ClientApp) StartAuthFlow(ctx context.Context, identifier string) (string, error) {
+
+	var authserverURL string
+	var accountDID syntax.DID
+
+	if strings.HasPrefix(identifier, "https://") {
+		authserverURL = identifier
+		identifier = ""
+	} else {
+		atid, err := syntax.ParseAtIdentifier(identifier)
+		if err != nil {
+			return "", fmt.Errorf("not a valid account identifier (%s): %w", identifier, err)
+		}
+		ident, err := app.Dir.Lookup(ctx, *atid)
+		if err != nil {
+			return "", fmt.Errorf("failed to resolve username (%s): %w", identifier, err)
+		}
+		host := ident.PDSEndpoint()
+		if host == "" {
+			return "", fmt.Errorf("identity does not link to an atproto host (PDS)")
+		}
+
+		// TODO: logger on ClientApp?
+		logger := slog.Default().With("did", ident.DID, "handle", ident.Handle, "host", host)
+		logger.Debug("resolving to auth server metadata")
+		authserverURL, err = app.Resolver.ResolveAuthServerURL(ctx, host)
+		if err != nil {
+			return "", fmt.Errorf("resolving auth server: %w", err)
+		}
+	}
+
+	authserverMeta, err := app.Resolver.ResolveAuthServerMetadata(ctx, authserverURL)
+	if err != nil {
+		return "", fmt.Errorf("fetching auth server metadata: %w", err)
+	}
+
+	scope := scopeStr(app.Config.Scopes)
+	info, err := app.SendAuthRequest(ctx, authserverMeta, scope, identifier)
+	if err != nil {
+		return "", fmt.Errorf("auth request failed: %w", err)
+	}
+
+	if accountDID != "" {
+		info.AccountDID = &accountDID
+	}
+
+	// persist auth request info
+	app.Store.SaveAuthRequestInfo(ctx, *info)
+
+	params := url.Values{}
+	params.Set("client_id", app.Config.ClientID)
+	params.Set("request_uri", info.RequestURI)
+
+	// AuthorizationEndpoint was already checked to be a clean URL
+	// TODO: could do additional SSRF checks on the redirect domain here
+	redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode())
+	return redirectURL, nil
+}
+
+// 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.
+func (app *ClientApp) ProcessCallback(ctx context.Context, params url.Values) (*ClientSessionData, error) {
+
+	state := params.Get("state")
+	authserverURL := params.Get("iss")
+	authCode := params.Get("code")
+	if state == "" || authserverURL == "" || authCode == "" {
+		return nil, fmt.Errorf("missing required query param")
+	}
+
+	info, err := app.Store.GetAuthRequestInfo(ctx, state)
+	if err != nil {
+		return nil, fmt.Errorf("loading auth request info: %w", err)
+	}
+
+	if info.State != state || info.AuthServerURL != authserverURL {
+		return nil, fmt.Errorf("callback params don't match request info")
+	}
+
+	tokenResp, err := app.SendInitialTokenRequest(ctx, authCode, *info)
+	if err != nil {
+		return nil, fmt.Errorf("initial token request: %w", err)
+	}
+
+	// verify against account/server from start of login
+	var accountDID syntax.DID
+	var hostURL string
+	if info.AccountDID != nil {
+		// if we started with an account DID, verify it against the subject
+		accountDID = *info.AccountDID
+		if tokenResp.Subject != info.AccountDID.String() {
+			return nil, fmt.Errorf("token subject didn't match original DID")
+		}
+		// identity lookup for PDS hostname; this should be cached
+		ident, err := app.Dir.LookupDID(ctx, accountDID)
+		if err != nil {
+			return nil, err
+		}
+		hostURL = ident.PDSEndpoint()
+	} else {
+		// if we started with an auth server URL, resolve and verify the identity
+		accountDID, err = syntax.ParseDID(tokenResp.Subject)
+		if err != nil {
+			return nil, err
+		}
+		ident, err := app.Dir.LookupDID(ctx, accountDID)
+		if err != nil {
+			return nil, err
+		}
+		hostURL = ident.PDSEndpoint()
+		res, err := app.Resolver.ResolveAuthServerURL(ctx, hostURL)
+		if err != nil {
+			return nil, fmt.Errorf("resolving auth server: %w", err)
+		}
+		if res != authserverURL {
+			return nil, fmt.Errorf("token subject auth server did not match original")
+		}
+	}
+
+	sessData := ClientSessionData{
+		AccountDID:              accountDID,
+		SessionID:               info.State,
+		HostURL:                 hostURL,
+		AuthServerURL:           info.AuthServerURL,
+		AuthServerTokenEndpoint: info.AuthServerTokenEndpoint,
+		Scopes:                  strings.Split(tokenResp.Scope, " "),
+		AccessToken:             tokenResp.AccessToken,
+		RefreshToken:            tokenResp.RefreshToken,
+		DPoPAuthServerNonce:     info.DPoPAuthServerNonce,
+		DPoPHostNonce:           info.DPoPAuthServerNonce, // bootstrap host nonce from authserver
+		DPoPPrivateKeyMultibase: info.DPoPPrivateKeyMultibase,
+	}
+	if err := app.Store.SaveSession(ctx, sessData); err != nil {
+		return nil, err
+	}
+	if err := app.Store.DeleteAuthRequestInfo(ctx, state); err != nil {
+		// only log on failure to delete state info
+		slog.Warn("failed to delete auth request info", "state", state, "did", accountDID, "authserver", info.AuthServerURL, "err", err)
+	}
+	return &sessData, nil
+}
diff --git a/atproto/auth/oauth/resolver.go b/atproto/auth/oauth/resolver.go
new file mode 100644
index 00000000..297c349d
--- /dev/null
+++ b/atproto/auth/oauth/resolver.go
@@ -0,0 +1,170 @@
+package oauth
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/bluesky-social/indigo/util/ssrf"
+)
+
+// Helper for resolving OAuth documents from the public web: client metadata, auth server metadata, etc.
+//
+// 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.
+type Resolver struct {
+	Client    *http.Client
+	UserAgent string
+}
+
+func NewResolver() *Resolver {
+	c := http.Client{
+		Timeout:   10 * time.Second,
+		Transport: ssrf.PublicOnlyTransport(),
+	}
+	return &Resolver{
+		Client:    &c,
+		UserAgent: "indigo-sdk",
+	}
+}
+
+// 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!
+//
+// Ensures that the returned URL is valid (eg, parses as a URL).
+func (r *Resolver) ResolveAuthServerURL(ctx context.Context, hostURL string) (string, error) {
+	u, err := url.Parse(hostURL)
+	if err != nil {
+		return "", err
+	}
+	// TODO: check against other resource server rules?
+	if u.Scheme != "https" || u.Hostname() == "" || u.Port() != "" {
+		return "", fmt.Errorf("not a valid public host URL: %s", hostURL)
+	}
+
+	docURL := fmt.Sprintf("https://%s/.well-known/oauth-protected-resource", u.Hostname())
+
+	// NOTE: this allows redirects
+	req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
+	if err != nil {
+		return "", err
+	}
+	if r.UserAgent != "" {
+		req.Header.Set("User-Agent", r.UserAgent)
+	}
+
+	resp, err := r.Client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("fetching protected resource document: %w", err)
+	}
+	defer resp.Body.Close()
+
+	// intentionally check for exactly HTTP 200 (not just 2xx)
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("HTTP error fetching protected resource document: %d", resp.StatusCode)
+	}
+
+	var body ProtectedResourceMetadata
+	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+		return "", fmt.Errorf("invalid protected resource document: %w", err)
+	}
+	if len(body.AuthorizationServers) < 1 {
+		return "", fmt.Errorf("no auth server URL in protected resource document")
+	}
+	authURL := body.AuthorizationServers[0]
+	au, err := url.Parse(body.AuthorizationServers[0])
+	if err != nil {
+		return "", fmt.Errorf("invalid auth server URL: %w", err)
+	}
+	if au.Scheme != "https" || au.Hostname() == "" || au.Port() != "" {
+		return "", fmt.Errorf("not a valid public auth server URL: %s", authURL)
+	}
+	return authURL, nil
+}
+
+// Resolves an Auth Server URL to server metadata. Validates metadata before returning.
+func (r *Resolver) ResolveAuthServerMetadata(ctx context.Context, serverURL string) (*AuthServerMetadata, error) {
+	u, err := url.Parse(serverURL)
+	if err != nil {
+		return nil, err
+	}
+	// TODO: check against other rules?
+	if u.Scheme != "https" || u.Hostname() == "" || u.Port() != "" {
+		return nil, fmt.Errorf("not a valid public host URL: %s", serverURL)
+	}
+
+	docURL := fmt.Sprintf("https://%s/.well-known/oauth-authorization-server", u.Hostname())
+
+	// NOTE: this allows redirects
+	req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
+	if err != nil {
+		return nil, err
+	}
+	if r.UserAgent != "" {
+		req.Header.Set("User-Agent", r.UserAgent)
+	}
+
+	resp, err := r.Client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("fetching auth server metadata: %w", err)
+	}
+	defer resp.Body.Close()
+
+	// NOTE: maybe any HTTP 2xx should be allowed?
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode)
+	}
+
+	var body AuthServerMetadata
+	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+		return nil, fmt.Errorf("invalid protected resource document: %w", err)
+	}
+
+	if err := body.Validate(serverURL); err != nil {
+		return nil, err
+	}
+	return &body, nil
+}
+
+// Fetches and validates OAuth client metadata document based on identifier in URL format.
+func (r *Resolver) ResolveClientMetadata(ctx context.Context, clientID string) (*ClientMetadata, error) {
+	u, err := url.Parse(clientID)
+	if err != nil {
+		return nil, err
+	}
+	// TODO: check against other rules?
+	if u.Scheme != "https" || u.Hostname() == "" || u.Port() != "" {
+		return nil, fmt.Errorf("not a valid public host URL: %s", clientID)
+	}
+
+	// NOTE: this allows redirects
+	req, err := http.NewRequestWithContext(ctx, "GET", clientID, nil)
+	if err != nil {
+		return nil, err
+	}
+	if r.UserAgent != "" {
+		req.Header.Set("User-Agent", r.UserAgent)
+	}
+
+	resp, err := r.Client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("fetching client metadata: %w", err)
+	}
+	defer resp.Body.Close()
+
+	// NOTE: maybe any HTTP 2xx should be allowed?
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode)
+	}
+
+	var body ClientMetadata
+	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+		return nil, fmt.Errorf("invalid client metadata document: %w", err)
+	}
+
+	if err := body.Validate(clientID); err != nil {
+		return nil, err
+	}
+	return &body, nil
+}
diff --git a/atproto/auth/oauth/session.go b/atproto/auth/oauth/session.go
new file mode 100644
index 00000000..472de08b
--- /dev/null
+++ b/atproto/auth/oauth/session.go
@@ -0,0 +1,357 @@
+package oauth
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"net/url"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/bluesky-social/indigo/atproto/client"
+	"github.com/bluesky-social/indigo/atproto/crypto"
+	"github.com/bluesky-social/indigo/atproto/syntax"
+
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/google/go-querystring/query"
+)
+
+type PersistSessionCallback = func(ctx context.Context, data *ClientSessionData)
+
+// Persisted information about an OAuth session. Used to resume an active session.
+type ClientSessionData struct {
+	// 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.
+	AccountDID syntax.DID `json:"account_did"`
+
+	// 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.
+	SessionID string `json:"session_id"`
+
+	// Base URL of the "resource server" (eg, PDS). Should include scheme, hostname, port; no path or auth info.
+	HostURL string `json:"host_url"`
+
+	// Base URL of the "auth server" (eg, PDS or entryway). Should include scheme, hostname, port; no path or auth info.
+	AuthServerURL string `json:"authserver_url"`
+
+	// Full token endpoint
+	AuthServerTokenEndpoint string `json:"authserver_token_endpoint"`
+
+	// The set of scopes approved for this session (returned in the initial token request)
+	Scopes []string `json:"scopes"`
+
+	// Token which can be used directly against host ("resource server", eg PDS)
+	AccessToken string `json:"access_token"`
+
+	// Token which can be sent to auth server (eg, PDS or entryway) to get a new access token
+	RefreshToken string `json:"refresh_token"`
+
+	// Current auth server DPoP nonce
+	DPoPAuthServerNonce string `json:"dpop_authserver_nonce"`
+
+	// Current host ("resource server", eg PDS) DPoP nonce
+	DPoPHostNonce string `json:"dpop_host_nonce"`
+
+	// The secret cryptographic key generated by the client for this specific OAuth session
+	DPoPPrivateKeyMultibase string `json:"dpop_privatekey_multibase"`
+
+	// TODO: also persist access token creation time / expiration time? In context that token might not be an easily parsed JWT
+}
+
+// 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.
+//
+// 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.
+type ClientSession struct {
+	// HTTP client used for token refresh requests
+	Client *http.Client
+
+	Config         *ClientConfig
+	Data           *ClientSessionData
+	DPoPPrivateKey crypto.PrivateKey
+
+	PersistSessionCallback PersistSessionCallback
+
+	// Lock which protects concurrent access to session data (eg, access and refresh tokens)
+	lk sync.RWMutex
+}
+
+// Requests new tokens from auth server, and returns the new access token on success.
+//
+// Internally takes a lock on session data around the entire refresh process, including retries. Persists data using [PersistSessionCallback] if configured.
+func (sess *ClientSession) RefreshTokens(ctx context.Context) (string, error) {
+	sess.lk.Lock()
+	defer sess.lk.Unlock()
+
+	body := RefreshTokenRequest{
+		ClientID:     sess.Config.ClientID,
+		GrantType:    "refresh_token",
+		RefreshToken: sess.Data.RefreshToken,
+	}
+	tokenURL := sess.Data.AuthServerTokenEndpoint
+
+	if sess.Config.IsConfidential() {
+		clientAssertion, err := sess.Config.NewClientAssertion(sess.Data.AuthServerURL)
+		if err != nil {
+			return "", err
+		}
+		body.ClientAssertionType = &ClientAssertionJWTBearer
+		body.ClientAssertion = &clientAssertion
+	}
+
+	vals, err := query.Values(body)
+	if err != nil {
+		return "", err
+	}
+	bodyBytes := []byte(vals.Encode())
+
+	var resp *http.Response
+	for range 2 {
+		dpopJWT, err := NewAuthDPoP("POST", sess.Data.AuthServerTokenEndpoint, sess.Data.DPoPAuthServerNonce, sess.DPoPPrivateKey)
+		if err != nil {
+			return "", err
+		}
+
+		req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, bytes.NewBuffer(bodyBytes))
+		if err != nil {
+			return "", err
+		}
+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+		req.Header.Set("DPoP", dpopJWT)
+
+		resp, err = sess.Client.Do(req)
+		if err != nil {
+			return "", err
+		}
+
+		// always check if a new DPoP nonce was provided, and proactively update session data (even if there was not an explicit error)
+		dpopNonceHdr := resp.Header.Get("DPoP-Nonce")
+		if dpopNonceHdr != "" && dpopNonceHdr != sess.Data.DPoPAuthServerNonce {
+			sess.Data.DPoPAuthServerNonce = dpopNonceHdr
+		}
+
+		// check for an error condition caused by an out of date DPoP nonce
+		// 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
+		if resp.StatusCode == http.StatusBadRequest && dpopNonceHdr != "" {
+			// parseAuthErrorReason() always closes resp.Body
+			reason := parseAuthErrorReason(resp, "token-refresh")
+			if reason == "use_dpop_nonce" {
+				// already updated nonce value above; loop around and try again
+				continue
+			}
+			return "", fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, reason)
+		}
+
+		// otherwise process response (success or other error type)
+		break
+	}
+
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		reason := parseAuthErrorReason(resp, "token-refresh")
+		return "", fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, reason)
+	}
+
+	var tokenResp TokenResponse
+	if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
+		return "", fmt.Errorf("token response failed to decode: %w", err)
+	}
+	// TODO: more validation of token refresh response?
+
+	sess.Data.AccessToken = tokenResp.AccessToken
+	sess.Data.RefreshToken = tokenResp.RefreshToken
+
+	// persist updated data (tokens and possibly nonce)
+	if sess.PersistSessionCallback != nil {
+		sess.PersistSessionCallback(ctx, sess.Data)
+	} else {
+		slog.Warn("not saving updated session data", "did", sess.Data.AccountDID, "session_id", sess.Data.SessionID)
+	}
+
+	return sess.Data.AccessToken, nil
+}
+
+// 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)
+func (sess *ClientSession) NewHostDPoP(method, reqURL string) (string, error) {
+	sess.lk.RLock()
+	defer sess.lk.RUnlock()
+
+	ath := S256CodeChallenge(sess.Data.AccessToken)
+	claims := dpopClaims{
+		HTTPMethod:      method,
+		TargetURI:       reqURL,
+		AccessTokenHash: &ath,
+		RegisteredClaims: jwt.RegisteredClaims{
+			Issuer:    sess.Data.AuthServerURL,
+			ID:        secureRandomBase64(16),
+			IssuedAt:  jwt.NewNumericDate(time.Now()),
+			ExpiresAt: jwt.NewNumericDate(time.Now().Add(jwtExpirationDuration)),
+		},
+	}
+	if sess.Data.DPoPHostNonce != "" {
+		claims.Nonce = &sess.Data.DPoPHostNonce
+	}
+
+	keyMethod, err := keySigningMethod(sess.DPoPPrivateKey)
+	if err != nil {
+		return "", err
+	}
+
+	// TODO: store a copy of this JWK on the ClientSession as a private field, for efficiency
+	pub, err := sess.DPoPPrivateKey.PublicKey()
+	if err != nil {
+		return "", err
+	}
+	pubJWK, err := pub.JWK()
+	if err != nil {
+		return "", err
+	}
+
+	token := jwt.NewWithClaims(keyMethod, claims)
+	token.Header["typ"] = "dpop+jwt"
+	token.Header["jwk"] = pubJWK
+	return token.SignedString(sess.DPoPPrivateKey)
+}
+
+// copy a request URL and strip query params and fragment, for DPoP
+func dpopURL(u *url.URL) string {
+	u2 := *u
+	u2.RawQuery = ""
+	u2.ForceQuery = false
+	u2.Fragment = ""
+	u2.RawFragment = ""
+	return u2.String()
+}
+
+// Parses a WWW-Authenticate response header to see if DPoP nonce update is indicated
+func isNonceUpdateHeader(hdr string) bool {
+	// Example from RFC9449:
+	// WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof"
+	return strings.Contains(hdr, "error=\"use_dpop_nonce\"")
+}
+
+// Parses a WWW-Authenticate response header to see if access token has expired (needs refresh)
+func isExpiredAccessTokenHeader(hdr string) bool {
+	// Example from OAuth 2.1 draft:
+	// WWW-Authenticate: Bearer error="invalid_token" error_description="The access token expired"
+	// TODO: should this also look for "expired"?
+	return strings.Contains(hdr, "error=\"invalid_token\"")
+}
+
+func (sess *ClientSession) GetHostAccessData() (accessToken string, dpopHostNonce string) {
+	sess.lk.RLock()
+	defer sess.lk.RUnlock()
+
+	return sess.Data.AccessToken, sess.Data.DPoPHostNonce
+}
+
+func (sess *ClientSession) UpdateHostDPoPNonce(ctx context.Context, nonce string) {
+	sess.lk.Lock()
+	defer sess.lk.Unlock()
+
+	sess.Data.DPoPHostNonce = nonce
+
+	if sess.PersistSessionCallback != nil {
+		sess.PersistSessionCallback(ctx, sess.Data)
+	} else {
+		slog.Warn("not saving updated host DPoP nonce", "did", sess.Data.AccountDID, "session_id", sess.Data.SessionID)
+	}
+}
+
+// Sends API request to OAuth Resource Server (PDS), using access token and DPoP.
+//
+// Automatically handles DPoP nonce updates and token refresh as needed, based on the response status code and `WWW-Authenticate` header.
+func (sess *ClientSession) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) {
+
+	durl := dpopURL(req.URL)
+
+	accessToken, dpopNonce := sess.GetHostAccessData()
+
+	// this method may need to retry twice, once for DPoP nonce update and once for token refresh
+	var resp *http.Response
+	for range 3 {
+		dpopJWT, err := sess.NewHostDPoP(req.Method, durl)
+		if err != nil {
+			return nil, err
+		}
+		req.Header.Set("Authorization", fmt.Sprintf("DPoP %s", accessToken))
+		req.Header.Set("DPoP", dpopJWT)
+
+		resp, err = c.Do(req)
+		if err != nil {
+			return nil, err
+		}
+
+		// on Success, or many types of error, just return HTTP response
+		// "Unauthorized" is HTTP status code 401
+		if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("WWW-Authenticate") == "" {
+			return resp, nil
+		}
+
+		authHdr := resp.Header.Get("WWW-Authenticate")
+		dpopNonceHdr := resp.Header.Get("DPoP-Nonce")
+
+		// if DPoP nonce changed, update and retry request
+		if isNonceUpdateHeader(authHdr) && dpopNonceHdr != "" {
+			// TODO: validate or normalize dpopNonceHdr in some way? eg minimum length
+			if dpopNonceHdr == dpopNonce {
+				return nil, fmt.Errorf("OAuth PDS DPoP nonce failure, but no new nonce supplied")
+			}
+
+			// persist new nonce value via callback
+			sess.UpdateHostDPoPNonce(req.Context(), dpopNonceHdr)
+			dpopNonce = dpopNonceHdr
+
+			// retry request
+			retry := req.Clone(req.Context())
+			if req.GetBody != nil {
+				retry.Body, err = req.GetBody()
+				if err != nil {
+					return nil, fmt.Errorf("GetBody failed when retrying API request: %w", err)
+				}
+			}
+			req = retry
+			continue
+		}
+
+		// if access token expired, refresh and retry
+		if isExpiredAccessTokenHeader(authHdr) {
+			accessToken, err = sess.RefreshTokens(req.Context())
+			if err != nil {
+				return nil, fmt.Errorf("failed to refresh OAuth tokens: %w", err)
+			}
+
+			retry := req.Clone(req.Context())
+			if req.GetBody != nil {
+				retry.Body, err = req.GetBody()
+				if err != nil {
+					return nil, fmt.Errorf("GetBody failed when retrying API request: %w", err)
+				}
+			}
+			req = retry
+			continue
+		}
+
+		// otherwise, this was some other type of auth failure; just return the full response
+		// NOTE: in theory we could return an APIError here instead
+		return resp, nil
+	}
+
+	return nil, fmt.Errorf("OAuth client ran out of request retries")
+}
+
+// Creates a new [client.APIClient] which wraps this session for auth.
+func (sess *ClientSession) APIClient() *client.APIClient {
+	c := client.APIClient{
+		Client:     sess.Client,
+		Host:       sess.Data.HostURL,
+		Auth:       sess,
+		AccountDID: &sess.Data.AccountDID,
+	}
+	if sess.Config.UserAgent != "" {
+		c.Headers = make(map[string][]string)
+		c.Headers.Set("User-Agent", sess.Config.UserAgent)
+	}
+	return &c
+}
diff --git a/atproto/auth/oauth/store.go b/atproto/auth/oauth/store.go
new file mode 100644
index 00000000..0bf36951
--- /dev/null
+++ b/atproto/auth/oauth/store.go
@@ -0,0 +1,24 @@
+package oauth
+
+import (
+	"context"
+
+	"github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+// Interface for persisting session data and auth request data, required as part of an OAuth client app.
+//
+// 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.
+//
+// For authorization-only (authn-only) applications, the `SaveSession()` method could be a no-op.
+//
+// Implementations should generally allow for concurrent access.
+type ClientAuthStore interface {
+	GetSession(ctx context.Context, did syntax.DID, sessionID string) (*ClientSessionData, error)
+	SaveSession(ctx context.Context, sess ClientSessionData) error
+	DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error
+
+	GetAuthRequestInfo(ctx context.Context, state string) (*AuthRequestData, error)
+	SaveAuthRequestInfo(ctx context.Context, info AuthRequestData) error
+	DeleteAuthRequestInfo(ctx context.Context, state string) error
+}
diff --git a/atproto/auth/oauth/types.go b/atproto/auth/oauth/types.go
new file mode 100644
index 00000000..3905eacf
--- /dev/null
+++ b/atproto/auth/oauth/types.go
@@ -0,0 +1,409 @@
+package oauth
+
+import (
+	"errors"
+	"fmt"
+	"net/url"
+	"slices"
+	"strings"
+
+	"github.com/bluesky-social/indigo/atproto/crypto"
+	"github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+var ClientAssertionJWTBearer string = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
+
+var (
+	ErrInvalidAuthServerMetadata = errors.New("invalid auth server metadata")
+	ErrInvalidClientMetadata     = errors.New("invalid client metadata doc")
+)
+
+type JWKS struct {
+	Keys []crypto.JWK `json:"keys"`
+}
+
+// Expected response type from looking up OAuth Protected Resource information on a server (eg, a PDS instance)
+type ProtectedResourceMetadata struct {
+	// are there other fields worth including?
+
+	AuthorizationServers []string `json:"authorization_servers"`
+}
+
+type ClientMetadata struct {
+	// Must exactly match the full URL used to fetch the client metadata file itself
+	ClientID string `json:"client_id"`
+
+	// Must be one of `web` or `native`, with `web` as the default if not specified.
+	ApplicationType *string `json:"application_type,omitempty"`
+
+	// `authorization_code` must always be included. `refresh_token` is optional, but must be included if the client will make token refresh requests.
+	GrantTypes []string `json:"grant_types"`
+
+	// All scope values which might be requested by the client are declared here. The `atproto` scope is required, so must be included here.
+	Scope string `json:"scope"`
+
+	// `code` must be included
+	ResponseTypes []string `json:"response_types"`
+
+	// At least one redirect URI is required.
+	RedirectURIs []string `json:"redirect_uris"`
+
+	// Confidential clients must set this to `private_key_jwt`; public must be `none`.
+	// 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.
+	TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
+
+	// `none` is never allowed here. The current recommended and most-supported algorithm is ES256, but this may evolve over time.
+	TokenEndpointAuthSigningAlg *string `json:"token_endpoint_auth_signing_alg,omitempty"`
+
+	// DPoP is mandatory for all clients, so this must be present and true
+	DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
+
+	// 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.
+	JWKS *JWKS `json:"jwks,omitempty"`
+
+	// URL pointing to a JWKS JSON object. See `jwks` above for details.
+	JWKSURI *string `json:"jwks_uri,omitempty"`
+
+	// human-readable name of the client
+	ClientName *string `json:"client_name,omitempty"`
+
+	// 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.
+	ClientURI *string `json:"client_uri,omitempty"`
+
+	// URL to client logo. Only https: URIs are allowed.
+	LogoURI *string `json:"logo_uri,omitempty"`
+
+	// URL to human-readable terms of service (ToS) for the client. Only https: URIs are allowed.
+	TosURI *string `json:"tos_uri,omitempty"`
+
+	// URL to human-readable privacy policy for the client. Only https: URIs are allowed.
+	PolicyURI *string `json:"policy_uri,omitempty"`
+}
+
+// returns 'true' if client metadata indicates that this is a confidential client
+func (m *ClientMetadata) IsConfidential() bool {
+	if (m.JWKSURI != nil || (m.JWKS != nil && len(m.JWKS.Keys) > 0)) && m.TokenEndpointAuthMethod == "private_key_jwt" {
+		return true
+	}
+
+	return false
+}
+
+func (m *ClientMetadata) Validate(clientID string) error {
+
+	if m.ClientID == "" || m.ClientID != clientID {
+		return fmt.Errorf("%w: client_id", ErrInvalidClientMetadata)
+	}
+
+	if m.ApplicationType != nil && !slices.Contains([]string{"web", "native"}, *m.ApplicationType) {
+		return fmt.Errorf("%w: application_type must be 'web', 'native', or undefined", ErrInvalidClientMetadata)
+	}
+
+	if !slices.Contains(m.GrantTypes, "authorization_code") {
+		return fmt.Errorf("%w: grant_type must include 'authorization_code'", ErrInvalidClientMetadata)
+	}
+
+	scopes := strings.Split(m.Scope, " ")
+	if !slices.Contains(scopes, "atproto") {
+		return fmt.Errorf("%w: scope must include 'atproto'", ErrInvalidClientMetadata)
+	}
+
+	if !slices.Contains(m.ResponseTypes, "code") {
+		return fmt.Errorf("%w: response_types must include 'code'", ErrInvalidClientMetadata)
+	}
+
+	if len(m.RedirectURIs) == 0 {
+		return fmt.Errorf("%w: redirect_uris must have at least one element", ErrInvalidClientMetadata)
+	}
+
+	// 'web' redirect URLs have more restrictions
+	if m.ApplicationType == nil || *m.ApplicationType == "web" {
+		for _, ru := range m.RedirectURIs {
+			u, err := url.Parse(ru)
+			if err != nil {
+				return fmt.Errorf("%w: invalid web redirect_uris: %w", ErrInvalidClientMetadata, err)
+			}
+			if u.Scheme != "https" && u.Hostname() != "127.0.0.1" {
+				return fmt.Errorf("%w: web redirect_uris must have 'https' scheme", ErrInvalidClientMetadata)
+			}
+		}
+	}
+
+	if !(m.TokenEndpointAuthMethod == "none" || m.TokenEndpointAuthMethod == "private_key_jwt") {
+		return fmt.Errorf("%w: unsupported token_endpoint_auth_method", ErrInvalidClientMetadata)
+	}
+
+	if m.TokenEndpointAuthSigningAlg != nil && *m.TokenEndpointAuthSigningAlg == "none" {
+		// NOTE: what if this is a public client?
+		return fmt.Errorf("%w: token_endpoint_auth_signing_alg must not be 'none'", ErrInvalidClientMetadata)
+	}
+
+	if !m.DPoPBoundAccessTokens {
+		return fmt.Errorf("%w: dpop_bound_access_tokens must be true (DPoP is required)", ErrInvalidClientMetadata)
+	}
+
+	if m.JWKSURI != nil && *m.JWKSURI == "" {
+		return fmt.Errorf("%w: jwks_uri must be valid URL (when provided)", ErrInvalidClientMetadata)
+	}
+
+	// NOTE: metadata URLs are not validated (they are not an error for overall metadata doc)
+
+	return nil
+}
+
+type AuthServerMetadata struct {
+
+	// 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.
+	Issuer string `json:"issuer"`
+
+	// endpoint URL for authorization redirects
+	AuthorizationEndpoint string `json:"authorization_endpoint"`
+
+	// endpoint URL for token requests
+	TokenEndpoint string `json:"token_endpoint"`
+
+	// must include code
+	ResponseTypesSupported []string `json:"response_types_supported"`
+
+	// must include authorization_code and refresh_token (refresh tokens must be supported)
+	GrantTypesSupported []string `json:"grant_types_supported"`
+
+	// must include S256
+	CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
+
+	// must include both none (public clients) and private_key_jwt (confidential clients)
+	TokenEndpointAuthMethodsSupoorted []string `json:"token_endpoint_auth_methods_supported"`
+
+	// must not include `none`. Must include ES256 for now.
+	TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"`
+
+	// must include atproto. If supporting the transitional grants, they should be included here as well.
+	ScopesSupported []string `json:"scopes_supported"`
+
+	// must be true
+	AuthorizationReponseISSParameterSupported bool `json:"authorization_response_iss_parameter_supported"`
+
+	// must be true
+	RequirePushedAuthorizationRequests bool `json:"require_pushed_authorization_requests"`
+
+	// corresponds to the PAR endpoint URL
+	PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
+
+	// currently must include ES256
+	DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
+
+	// default is true; does not need to be set explicitly, but must not be false
+	RequireRequestURIRegistration *bool `json:"require_request_uri_registration,omitempty"`
+
+	// must be true
+	ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"`
+}
+
+func (m *AuthServerMetadata) Validate(serverURL string) error {
+
+	if m.Issuer == "" {
+		return fmt.Errorf("%w: empty issuer", ErrInvalidAuthServerMetadata)
+	}
+	u, err := url.Parse(m.Issuer)
+	if err != nil {
+		return fmt.Errorf("%w: invalid issuer URL: %w", ErrInvalidAuthServerMetadata, err)
+	}
+	if u.Scheme != "https" || u.Port() != "" || u.Path != "" || u.Fragment != "" || u.RawQuery != "" {
+		return fmt.Errorf("%w: issuer URL", ErrInvalidAuthServerMetadata)
+	}
+
+	// check that Issuer matches domain this metadata document was fetched from
+	srvu, err := url.Parse(serverURL)
+	if err != nil {
+		return fmt.Errorf("%w: invalid request URL: %w", ErrInvalidAuthServerMetadata, err)
+	}
+	if u.Scheme != srvu.Scheme || u.Host != srvu.Host {
+		return fmt.Errorf("%w: issuer must match request URL", ErrInvalidAuthServerMetadata)
+	}
+
+	// check that authorization endpoint is a valid HTTPS URL with no fragment or query params (we will be appending query params latter)
+	aeurl, err := url.Parse(m.AuthorizationEndpoint)
+	if err != nil {
+		return fmt.Errorf("%w: invalid auth endpoint URL (%s): %w", ErrInvalidAuthServerMetadata, m.AuthorizationEndpoint, err)
+	}
+	if aeurl.Scheme != "https" || u.Fragment != "" || u.RawQuery != "" {
+		return fmt.Errorf("%w: invalid auth endpoint URL: %s", ErrInvalidAuthServerMetadata, m.AuthorizationEndpoint)
+	}
+
+	if !slices.Contains(m.ResponseTypesSupported, "code") {
+		return fmt.Errorf("%w: response_types_supported must include 'code'", ErrInvalidAuthServerMetadata)
+	}
+	if !slices.Contains(m.GrantTypesSupported, "authorization_code") {
+		return fmt.Errorf("%w: grant_types_supported must include 'authorization_code'", ErrInvalidAuthServerMetadata)
+	}
+	if !slices.Contains(m.GrantTypesSupported, "refresh_token") {
+		return fmt.Errorf("%w: grant_types_supported must include 'refresh_token'", ErrInvalidAuthServerMetadata)
+	}
+	if !slices.Contains(m.CodeChallengeMethodsSupported, "S256") {
+		return fmt.Errorf("%w: code_challenge_method must include 'S256'", ErrInvalidAuthServerMetadata)
+	}
+	if !slices.Contains(m.TokenEndpointAuthMethodsSupoorted, "none") {
+		return fmt.Errorf("%w: token_endpoint_auth_methods_supported must include 'none'", ErrInvalidAuthServerMetadata)
+	}
+	if !slices.Contains(m.TokenEndpointAuthMethodsSupoorted, "private_key_jwt") {
+		return fmt.Errorf("%w: token_endpoint_auth_methods_supported must include 'private_key_jwt'", ErrInvalidAuthServerMetadata)
+	}
+	if !slices.Contains(m.TokenEndpointAuthSigningAlgValuesSupported, "ES256") {
+		return fmt.Errorf("%w: token_endpoint_auth_signing_alg_values_supported must include 'ES256'", ErrInvalidAuthServerMetadata)
+	}
+	if !slices.Contains(m.ScopesSupported, "atproto") {
+		return fmt.Errorf("%w: scopes_supported must include 'atproto'", ErrInvalidAuthServerMetadata)
+	}
+	if !m.AuthorizationReponseISSParameterSupported {
+		return fmt.Errorf("%w: authorization_response_iss_parameter_supported must be true", ErrInvalidAuthServerMetadata)
+	}
+	if !m.RequirePushedAuthorizationRequests {
+		return fmt.Errorf("%w: require_pushed_authorization_requests must be true", ErrInvalidAuthServerMetadata)
+	}
+	if m.PushedAuthorizationRequestEndpoint == "" {
+		return fmt.Errorf("%w: pushed_authorization_request_endpoint is required", ErrInvalidAuthServerMetadata)
+	}
+	if !slices.Contains(m.DPoPSigningAlgValuesSupported, "ES256") {
+		return fmt.Errorf("%w: dpop_signing_alg_values_supported must include 'ES256'", ErrInvalidAuthServerMetadata)
+	}
+	if m.RequireRequestURIRegistration != nil && *m.RequireRequestURIRegistration != true {
+		return fmt.Errorf("%w: require_request_uri_registration must be undefined or true", ErrInvalidAuthServerMetadata)
+	}
+	if !m.ClientIDMetadataDocumentSupported {
+		return fmt.Errorf("%w: client_id_metadata_document_supported must be true", ErrInvalidAuthServerMetadata)
+	}
+	return nil
+}
+
+// The fields which are included in a PAR request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON.
+type PushedAuthRequest struct {
+	// Client ID, aka client metadata URL
+	ClientID string `url:"client_id"`
+
+	// Random identifier for this request, generated by client
+	State string `url:"state"`
+
+	// Client-specified URL that will get redirected to by auth server at end of user auth flow
+	RedirectURI string `url:"redirect_uri"`
+
+	// Requested auth scopes, as a space-delimited list
+	Scope string `url:"scope"`
+
+	// Optional account identifier (DID or handle) to help with user account login and/or account switching
+	LoginHint *string `url:"login_hint,omitempty"`
+
+	// Optional hint to auth server of what expected auth behavior should be. Eg, 'create', 'none', 'consent', 'login', 'select_account'
+	Prompt *string `url:"prompt,omitempty"`
+
+	// Always "code"
+	ResponseType string `url:"response_type"`
+
+	// Always "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
+	ClientAssertionType string `url:"client_assertion_type"`
+
+	// Confidential client signed JWT
+	ClientAssertion string `url:"client_assertion"`
+
+	// Client-generated PKCE challenge hash, derived from random "verifier" string
+	CodeChallenge string `url:"code_challenge"`
+
+	// Almost always "S256"
+	CodeChallengeMethod string `url:"code_challenge_method"`
+}
+
+type PushedAuthResponse struct {
+	// unique token in URI format, which will be used by the client in the auth flow redirect
+	RequestURI string `json:"request_uri"`
+
+	// positive integer indicating number of seconds the `request_uri` is valid for.
+	ExpiresIn int `json:"expires_in"`
+}
+
+// Persisted information about an OAuth Auth Request.
+type AuthRequestData struct {
+	// The random identifier generated by the client for the auth request flow. Can be used as "primary key" for storing and retrieving this information.
+	State string `json:"state"`
+
+	// URL of the auth server (eg, PDS or entryway)
+	AuthServerURL string `json:"authserver_url"`
+
+	// If the flow started with an account identifier (DID or handle), it should be persisted, to verify against the initial token response.
+	AccountDID *syntax.DID `json:"account_did,omitempty"`
+
+	// OAuth scope string (space-separated list)
+	Scope string `json:"scope"`
+
+	// unique token in URI format, which will be used by the client in the auth flow redirect
+	RequestURI string `json:"request_uri"`
+
+	// Full token endpoint URL
+	AuthServerTokenEndpoint string `json:"authserver_token_endpoint"`
+
+	// The secret token/nonce which a code challenge was generated from
+	PKCEVerifier string `json:"pkce_verifier"`
+
+	// Server-provided DPoP nonce from auth request (PAR)
+	DPoPAuthServerNonce string `json:"dpop_authserver_nonce"`
+
+	// The secret cryptographic key generated by the client for this specific OAuth session
+	DPoPPrivateKeyMultibase string `json:"dpop_privatekey_multibase"`
+}
+
+// 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.
+type InitialTokenRequest struct {
+	// Client ID, aka client metadata URL
+	ClientID string `url:"client_id"`
+
+	// 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)
+	RedirectURI string `url:"redirect_uri"`
+
+	// Always `authorization_code`
+	GrantType string `url:"grant_type"`
+
+	// Refresh token
+	RefreshToken string `url:"refresh_token"`
+
+	// Authorization Code provided by the Auth Server via callback at the end of the auth request flow
+	Code string `url:"code"`
+
+	// PKCE verifier string. Only included in initial token request
+	CodeVerifier string `url:"code_verifier"`
+
+	// For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
+	ClientAssertionType *string `url:"client_assertion_type"`
+
+	// For confidential clients, the signed client assertion JWT
+	ClientAssertion *string `url:"client_assertion"`
+}
+
+// The fields which are included in a token refresh request. These HTTP POST bodies are form-encoded, so use URL encoding syntax, not JSON.
+type RefreshTokenRequest struct {
+	// Client ID, aka client metadata URL
+	ClientID string `url:"client_id"`
+
+	// Always `authorization_code`
+	GrantType string `url:"grant_type"`
+
+	// Refresh token.
+	RefreshToken string `url:"refresh_token"`
+
+	// For confidential clients, must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
+	ClientAssertionType *string `url:"client_assertion_type"`
+
+	// For confidential clients, the signed client assertion JWT
+	ClientAssertion *string `url:"client_assertion"`
+}
+
+// Expected response from Auth Server token endpoint, both for initial token request and for refresh requests.
+type TokenResponse struct {
+	Subject string `json:"sub"`
+
+	// Usually expected to be the scopes that the client requested, but technically only a subset may have been approved, or additional scopes granted (?).
+	Scope string `json:"scope"`
+
+	// Opaque access token, for requests to the resource server.
+	AccessToken string `json:"access_token"`
+
+	// Refresh token, for doing additional token requests to the auth server.
+	RefreshToken string `json:"refresh_token"`
+}
diff --git a/atproto/auth/oauth/util.go b/atproto/auth/oauth/util.go
new file mode 100644
index 00000000..b8ede04a
--- /dev/null
+++ b/atproto/auth/oauth/util.go
@@ -0,0 +1,24 @@
+package oauth
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+)
+
+// This is used both for PKCE challenges, and for pseudo-unique nonces to prevent token (JWT) replay.
+func secureRandomBase64(sizeBytes uint) string {
+	buf := make([]byte, sizeBytes)
+	rand.Read(buf)
+	return base64.RawURLEncoding.EncodeToString(buf)
+}
+
+// Computes an SHA-256 base64url-encoded challenge string, as used for PKCE.
+func S256CodeChallenge(raw string) string {
+	b := sha256.Sum256([]byte(raw))
+	return base64.RawURLEncoding.EncodeToString(b[:])
+}
+
+func strPtr(raw string) *string {
+	return &raw
+}
diff --git a/cmd/goat/account_plc.go b/cmd/goat/account_plc.go
index 910be2f7..95a6715f 100644
--- a/cmd/goat/account_plc.go
+++ b/cmd/goat/account_plc.go
@@ -11,6 +11,7 @@ import (
 	comatproto "github.com/bluesky-social/indigo/api/atproto"
 	"github.com/bluesky-social/indigo/atproto/crypto"
 	"github.com/bluesky-social/indigo/atproto/syntax"
+	"github.com/did-method-plc/go-didplc"
 
 	"github.com/urfave/cli/v2"
 )
@@ -186,14 +187,30 @@ func runAccountPlcSubmit(cctx *cli.Context) error {
 		return err
 	}
 
-	var op json.RawMessage
-	if err = json.Unmarshal(fileBytes, &op); err != nil {
+	var opEnum didplc.OpEnum
+	if err = json.Unmarshal(fileBytes, &opEnum); err != nil {
 		return fmt.Errorf("failed decoding PLC op JSON: %w", err)
 	}
+	op := opEnum.AsOperation()
 
+	if op.IsGenesis() {
+		return fmt.Errorf("can't submit a genesis operation via PDS (HINT: Make sure the prev field is set, or try `goat plc submit --genesis`)")
+	}
+
+	if !op.IsSigned() {
+		return fmt.Errorf("operation must be signed (HINT: try `goat account plc sign`)")
+	}
+
+	// convert it back to JSON for submission
+	opEncoded, err := json.Marshal(op)
+	if err != nil {
+		return err
+	}
+	rawMsg := json.RawMessage(opEncoded)
 	err = agnostic.IdentitySubmitPlcOperation(ctx, xrpcc, &agnostic.IdentitySubmitPlcOperation_Input{
-		Operation: &op,
+		Operation: &rawMsg,
 	})
+
 	if err != nil {
 		return fmt.Errorf("failed submitting PLC op via PDS: %w", err)
 	}
diff --git a/cmd/goat/plc.go b/cmd/goat/plc.go
index c9900fba..3ba9222f 100644
--- a/cmd/goat/plc.go
+++ b/cmd/goat/plc.go
@@ -517,7 +517,7 @@ func runPLCSign(cctx *cli.Context) error {
 
 	privStr := cctx.String("plc-signing-key")
 	if privStr == "" {
-		return fmt.Errorf("private key must be provided")
+		return fmt.Errorf("private key must be provided (HINT: use `goat account plc` if your PDS holds the keys)")
 	}
 
 	inputReader, err := getFileOrStdin(s)
@@ -587,7 +587,7 @@ func runPLCSubmit(cctx *cli.Context) error {
 
 	var enum didplc.OpEnum
 	if err := json.Unmarshal(inBytes, &enum); err != nil {
-		return err
+		return fmt.Errorf("failed decoding PLC op JSON: %w", err)
 	}
 	op := enum.AsOperation()
 
diff --git a/go.mod b/go.mod
index 412679d1..b2327f5c 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,8 @@ require (
 	github.com/gocql/gocql v1.7.0
 	github.com/golang-jwt/jwt v3.2.2+incompatible
 	github.com/golang-jwt/jwt/v5 v5.2.2
+	github.com/google/go-querystring v1.1.0
+	github.com/gorilla/sessions v1.2.1
 	github.com/gorilla/websocket v1.5.1
 	github.com/hashicorp/go-retryablehttp v0.7.5
 	github.com/hashicorp/golang-lru/arc/v2 v2.0.6
@@ -93,6 +95,7 @@ require (
 	github.com/go-redis/redis v6.15.9+incompatible // indirect
 	github.com/goccy/go-json v0.10.2 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
+	github.com/gorilla/securecookie v1.1.1 // indirect
 	github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
 	github.com/hashicorp/golang-lru v1.0.2 // indirect
 	github.com/ipfs/go-log v1.0.5 // indirect
diff --git a/go.sum b/go.sum
index a08126a6..ca49ae8c 100644
--- a/go.sum
+++ b/go.sum
@@ -140,11 +140,14 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
 github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
@@ -154,6 +157,10 @@ github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
 github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
 github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 h1:6UKoz5ujsI55KNpsJH3UwCq3T8kKbZwNZBNPuTTje8U=



Test patch:
diff --git a/atproto/auth/oauth/resolver_test.go b/atproto/auth/oauth/resolver_test.go
new file mode 100644
index 00000000..9eb7be19
--- /dev/null
+++ b/atproto/auth/oauth/resolver_test.go
@@ -0,0 +1,149 @@
+package oauth
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// TODO: localhost (dev mode) resolution
+
+func TestValidateMetadata(t *testing.T) {
+	assert := assert.New(t)
+
+	{
+		var meta ProtectedResourceMetadata
+		b, err := os.ReadFile("testdata/morel-protected-resource.json")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if err := json.Unmarshal(b, &meta); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	{
+		var meta ProtectedResourceMetadata
+		b, err := os.ReadFile("testdata/indie-protected-resource.json")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if err := json.Unmarshal(b, &meta); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	{
+		var meta AuthServerMetadata
+		b, err := os.ReadFile("testdata/bsky-entryway-authorization-server.json")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if err := json.Unmarshal(b, &meta); err != nil {
+			t.Fatal(err)
+		}
+		assert.NoError(meta.Validate("https://bsky.social/.well-known/oauth-authorization-server"))
+	}
+
+	{
+		var meta AuthServerMetadata
+		b, err := os.ReadFile("testdata/indie-authorization-server.json")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if err := json.Unmarshal(b, &meta); err != nil {
+			t.Fatal(err)
+		}
+		assert.NoError(meta.Validate("https://pds.robocracy.org/.well-known/oauth-authorization-server"))
+	}
+
+	{
+		var meta ClientMetadata
+		b, err := os.ReadFile("testdata/flaskdemo-client-metadata.json")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if err := json.Unmarshal(b, &meta); err != nil {
+			t.Fatal(err)
+		}
+		assert.NoError(meta.Validate("https://oauth-flask.demo.bsky.dev/oauth/client-metadata.json"))
+	}
+
+	{
+		var meta ClientMetadata
+		b, err := os.ReadFile("testdata/statusphere-client-metadata.json")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if err := json.Unmarshal(b, &meta); err != nil {
+			t.Fatal(err)
+		}
+		assert.NoError(meta.Validate("https://statusphere.mozzius.dev/oauth-client-metadata.json"))
+	}
+
+	{
+		var meta ClientMetadata
+		b, err := os.ReadFile("testdata/smokesignal-client-metadata.json")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if err := json.Unmarshal(b, &meta); err != nil {
+			t.Fatal(err)
+		}
+		assert.NoError(meta.Validate("https://smokesignal.events/oauth/client-metadata.json"))
+	}
+
+	{
+		var meta JWKS
+		b, err := os.ReadFile("testdata/flaskdemo-jwks.json")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if err := json.Unmarshal(b, &meta); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	{
+		var meta JWKS
+		b, err := os.ReadFile("testdata/smokesignal-jwks.json")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if err := json.Unmarshal(b, &meta); err != nil {
+			t.Fatal(err)
+		}
+	}
+}
+
+func TestResolver(t *testing.T) {
+	assert := assert.New(t)
+	ctx := context.Background()
+
+	resolver := NewResolver()
+
+	{
+		// Live network tests (disabled by default)
+		/*
+			_, err := resolver.ResolveAuthServerURL(ctx, "https://morel.us-east.host.bsky.network")
+			assert.NoError(err)
+			_, err = resolver.ResolveAuthServerMetadata(ctx, "https://bsky.social")
+			assert.NoError(err)
+			_, err = resolver.ResolveClientMetadata(ctx, "https://oauth-flask.demo.bsky.dev/oauth/client-metadata.json")
+			assert.NoError(err)
+		*/
+	}
+
+	{
+		// local unsafe should fail
+		_, err := resolver.ResolveAuthServerURL(ctx, "https://127.0.0.1")
+		assert.ErrorContains(err, "is not a public IP address")
+		_, err = resolver.ResolveAuthServerMetadata(ctx, "https://10.0.0.1")
+		assert.ErrorContains(err, "is not a public IP address")
+		_, err = resolver.ResolveClientMetadata(ctx, "https://127.0.0.1/oauth/client-metadata.json")
+		assert.ErrorContains(err, "is not a public IP address")
+	}
+}
diff --git a/atproto/auth/oauth/testdata/bsky-entryway-authorization-server.json b/atproto/auth/oauth/testdata/bsky-entryway-authorization-server.json
new file mode 100644
index 00000000..ec9419e8
--- /dev/null
+++ b/atproto/auth/oauth/testdata/bsky-entryway-authorization-server.json
@@ -0,0 +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}
\ No newline at end of file
diff --git a/atproto/auth/oauth/testdata/flaskdemo-client-metadata.json b/atproto/auth/oauth/testdata/flaskdemo-client-metadata.json
new file mode 100644
index 00000000..ab75b4ec
--- /dev/null
+++ b/atproto/auth/oauth/testdata/flaskdemo-client-metadata.json
@@ -0,0 +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"}
diff --git a/atproto/auth/oauth/testdata/flaskdemo-jwks.json b/atproto/auth/oauth/testdata/flaskdemo-jwks.json
new file mode 100644
index 00000000..8f99fbb5
--- /dev/null
+++ b/atproto/auth/oauth/testdata/flaskdemo-jwks.json
@@ -0,0 +1 @@
+{"keys":[{"crv":"P-256","kid":"demo-1723096995","kty":"EC","x":"9xgcqvuBJIN8M32cvXc7vZDc4xgaYvrEMh8LHH1Uz0E","y":"ar8LYDXhUp32aNjq-Ko5jKVFUZNLSYxm7okrU2KIUqk"}]}
diff --git a/atproto/auth/oauth/testdata/indie-authorization-server.json b/atproto/auth/oauth/testdata/indie-authorization-server.json
new file mode 100644
index 00000000..15212ee1
--- /dev/null
+++ b/atproto/auth/oauth/testdata/indie-authorization-server.json
@@ -0,0 +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}
\ No newline at end of file
diff --git a/atproto/auth/oauth/testdata/indie-protected-resource.json b/atproto/auth/oauth/testdata/indie-protected-resource.json
new file mode 100644
index 00000000..163ecb8d
--- /dev/null
+++ b/atproto/auth/oauth/testdata/indie-protected-resource.json
@@ -0,0 +1 @@
+{"resource":"https://pds.robocracy.org","authorization_servers":["https://pds.robocracy.org"],"scopes_supported":[],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}
\ No newline at end of file
diff --git a/atproto/auth/oauth/testdata/morel-protected-resource.json b/atproto/auth/oauth/testdata/morel-protected-resource.json
new file mode 100644
index 00000000..c12d2cd5
--- /dev/null
+++ b/atproto/auth/oauth/testdata/morel-protected-resource.json
@@ -0,0 +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"}
\ No newline at end of file
diff --git a/atproto/auth/oauth/testdata/smokesignal-client-metadata.json b/atproto/auth/oauth/testdata/smokesignal-client-metadata.json
new file mode 100644
index 00000000..81402d26
--- /dev/null
+++ b/atproto/auth/oauth/testdata/smokesignal-client-metadata.json
@@ -0,0 +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/"}
\ No newline at end of file
diff --git a/atproto/auth/oauth/testdata/smokesignal-jwks.json b/atproto/auth/oauth/testdata/smokesignal-jwks.json
new file mode 100644
index 00000000..d17e2058
--- /dev/null
+++ b/atproto/auth/oauth/testdata/smokesignal-jwks.json
@@ -0,0 +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"}]}
\ No newline at end of file
diff --git a/atproto/auth/oauth/testdata/statusphere-client-metadata.json b/atproto/auth/oauth/testdata/statusphere-client-metadata.json
new file mode 100644
index 00000000..2f8c2fdc
--- /dev/null
+++ b/atproto/auth/oauth/testdata/statusphere-client-metadata.json
@@ -0,0 +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}
\ No newline at end of file