diff --git a/pkg/appview/db/oauth_store.go b/pkg/appview/db/oauth_store.go
index 76d894c..4fd5cfb 100644
--- a/pkg/appview/db/oauth_store.go
+++ b/pkg/appview/db/oauth_store.go
@@ -377,7 +377,7 @@ func (s *OAuthStore) CleanupExpiredAuthRequests(ctx context.Context) {
}
}
-// InvalidateSessionsWithMismatchedScopes removes all sessions whose scopes don't match the desired scopes
+// InvalidateSessionsWithMismatchedScopes removes all sessions that don't cover the desired scopes
// This is called on AppView startup to ensure all sessions have current scopes
// Returns the count of invalidated sessions
func (s *OAuthStore) InvalidateSessionsWithMismatchedScopes(ctx context.Context, desiredScopes []string) (int, error) {
@@ -408,14 +408,15 @@ func (s *OAuthStore) InvalidateSessionsWithMismatchedScopes(ctx context.Context,
continue
}
- // Check if scopes match (expands include: scopes before comparing)
- if !atoauth.ScopesMatch(sessionData.Scopes, desiredScopes) {
- slog.Debug("Session has mismatched scopes",
+ // Evict sessions missing a scope we now ask for. Extra grants are
+ // fine: a PDS may add its own, and dropping a scope from the desired
+ // set shouldn't sign everyone out.
+ if missing := atoauth.MissingScopes(sessionData.Scopes, desiredScopes); len(missing) > 0 {
+ slog.Debug("Session is missing scopes",
"component", "oauth/store",
"session_key", sessionKey,
"account_did", accountDID,
- "session_scopes", sessionData.Scopes,
- "desired_scopes", desiredScopes,
+ "missing", missing,
)
sessionsToDelete = append(sessionsToDelete, sessionKey)
}
diff --git a/pkg/appview/db/oauth_store_test.go b/pkg/appview/db/oauth_store_test.go
index cd12552..58e4d53 100644
--- a/pkg/appview/db/oauth_store_test.go
+++ b/pkg/appview/db/oauth_store_test.go
@@ -5,7 +5,6 @@ import (
"testing"
"time"
- atcroauth "atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
@@ -91,7 +90,7 @@ func TestInvalidateSessionsWithMismatchedScopes(t *testing.T) {
t.Error("Expected session to be deleted (should error), but got no error")
}
- // Test 4: Session with extra scopes - should be invalidated
+ // Test 4: Session with extra scopes - kept; only missing scopes evict
extraScopeSession := createSession("did:plc:test3", "session3", []string{"atproto", "blob:image/png", "extra:scope"})
if err := store.SaveSession(ctx, extraScopeSession); err != nil {
t.Fatalf("Failed to save extra scope session: %v", err)
@@ -101,8 +100,8 @@ func TestInvalidateSessionsWithMismatchedScopes(t *testing.T) {
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
- if count != 1 {
- t.Errorf("Expected 1 invalidated session (extra scope), got %d", count)
+ if count != 0 {
+ t.Errorf("Expected 0 invalidated sessions (extra scope), got %d", count)
}
// Test 5: Multiple sessions with mixed matches - only mismatch should be invalidated
@@ -161,75 +160,6 @@ func TestInvalidateSessionsWithMismatchedScopes(t *testing.T) {
}
}
-func TestScopesMatch(t *testing.T) {
- // Test oauth.ScopesMatch function including include: scope expansion
- tests := []struct {
- name string
- stored []string
- desired []string
- expected bool
- }{
- {
- name: "exact match",
- stored: []string{"atproto", "blob:image/png"},
- desired: []string{"atproto", "blob:image/png"},
- expected: true,
- },
- {
- name: "different order",
- stored: []string{"blob:image/png", "atproto"},
- desired: []string{"atproto", "blob:image/png"},
- expected: true,
- },
- {
- name: "missing scope",
- stored: []string{"atproto"},
- desired: []string{"atproto", "blob:image/png"},
- expected: false,
- },
- {
- name: "extra scope",
- stored: []string{"atproto", "blob:image/png", "extra"},
- desired: []string{"atproto", "blob:image/png"},
- expected: false,
- },
- {
- name: "both empty",
- stored: []string{},
- desired: []string{},
- expected: true,
- },
- {
- name: "nil vs empty",
- stored: nil,
- desired: []string{},
- expected: true,
- },
- {
- name: "include scope expansion",
- stored: []string{
- "atproto",
- "repo?collection=io.atcr.manifest&collection=io.atcr.repo.page&collection=io.atcr.sailor.profile&collection=io.atcr.sailor.star&collection=io.atcr.tag",
- },
- desired: []string{
- "atproto",
- "include:io.atcr.authFullApp",
- },
- expected: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := atcroauth.ScopesMatch(tt.stored, tt.desired)
- if result != tt.expected {
- t.Errorf("ScopesMatch(%v, %v) = %v, want %v",
- tt.stored, tt.desired, result, tt.expected)
- }
- })
- }
-}
-
func TestOAuthStoreSessionLifecycle(t *testing.T) {
// Basic test to ensure SaveSession, GetSession, DeleteSession work correctly
db, err := InitDB(":memory:", LibsqlConfig{})
diff --git a/pkg/appview/handlers/api.go b/pkg/appview/handlers/api.go
index 50d13b2..94e1ed7 100644
--- a/pkg/appview/handlers/api.go
+++ b/pkg/appview/handlers/api.go
@@ -67,6 +67,10 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
+ if msg, ok := pdsDenied(err); ok {
+ http.Error(w, msg, http.StatusForbidden)
+ return
+ }
slog.Error("Failed to create star record", "error", err)
http.Error(w, fmt.Sprintf("Failed to create star: %v", err), http.StatusInternalServerError)
return
@@ -137,6 +141,10 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
+ if msg, ok := pdsDenied(err); ok {
+ http.Error(w, msg, http.StatusForbidden)
+ return
+ }
slog.Error("Failed to delete star record", "error", err)
http.Error(w, fmt.Sprintf("Failed to delete star: %v", err), http.StatusInternalServerError)
return
diff --git a/pkg/appview/handlers/images.go b/pkg/appview/handlers/images.go
index d0e505f..e10722d 100644
--- a/pkg/appview/handlers/images.go
+++ b/pkg/appview/handlers/images.go
@@ -74,6 +74,10 @@ func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
+ if msg, ok := pdsDenied(err); ok {
+ http.Error(w, msg, http.StatusForbidden)
+ return
+ }
http.Error(w, fmt.Sprintf("Failed to delete tag from PDS: %v", err), http.StatusInternalServerError)
return
}
@@ -200,6 +204,10 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
+ if msg, ok := pdsDenied(err); ok {
+ http.Error(w, msg, http.StatusForbidden)
+ return
+ }
http.Error(w, fmt.Sprintf("Failed to delete tag '%s' from PDS: %v", tag, err), http.StatusInternalServerError)
return
}
@@ -250,6 +258,10 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
+ if msg, ok := pdsDenied(err); ok {
+ http.Error(w, msg, http.StatusForbidden)
+ return
+ }
http.Error(w, fmt.Sprintf("Failed to delete manifest from PDS: %v", err), http.StatusInternalServerError)
return
}
@@ -467,6 +479,10 @@ func (h *UploadAvatarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
+ if msg, ok := pdsDenied(err); ok {
+ http.Error(w, msg, http.StatusForbidden)
+ return
+ }
http.Error(w, fmt.Sprintf("Failed to upload image: %v", err), http.StatusInternalServerError)
return
}
@@ -505,6 +521,10 @@ func (h *UploadAvatarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
+ if msg, ok := pdsDenied(err); ok {
+ http.Error(w, msg, http.StatusForbidden)
+ return
+ }
http.Error(w, fmt.Sprintf("Failed to update repository page: %v", err), http.StatusInternalServerError)
return
}
diff --git a/pkg/appview/handlers/oauth_errors.go b/pkg/appview/handlers/oauth_errors.go
index 313d7e0..1ee49bc 100644
--- a/pkg/appview/handlers/oauth_errors.go
+++ b/pkg/appview/handlers/oauth_errors.go
@@ -26,16 +26,19 @@ func isOAuthError(err error) bool {
}
// Check structured error types first
+ // A 403 is deliberately not here: the PDS returns it for a session that
+ // is alive but lacks a permission (InsufficientScope), and deleting the
+ // session over that signs the user out without fixing anything.
var xrpcErr *xrpc.Error
- if errors.As(err, &xrpcErr) && (xrpcErr.StatusCode == 401 || xrpcErr.StatusCode == 403) {
+ if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == 401 {
return true
}
var apiErr *atclient.APIError
if errors.As(err, &apiErr) {
- if apiErr.StatusCode == 401 || apiErr.StatusCode == 403 {
+ if apiErr.StatusCode == 401 {
return true
}
- if apiErr.Name == "InvalidToken" || apiErr.Name == "InsufficientScope" || apiErr.Name == "InvalidGrant" {
+ if apiErr.Name == "InvalidToken" || apiErr.Name == "InvalidGrant" {
return true
}
}
@@ -88,3 +91,24 @@ func handleOAuthError(ctx context.Context, refresher *oauth.Refresher, did strin
return true
}
+
+// pdsDenied reports whether err is the user's PDS refusing a write (HTTP 403),
+// and the message to show them. The session is fine; its grant is missing a
+// permission, which PDSes spell differently (InsufficientScope,
+// ScopeMissingError), so only the status is checked. Callers answer 403 with
+// the message instead of a 500 that explains nothing.
+func pdsDenied(err error) (string, bool) {
+ var apiErr *atclient.APIError
+ if errors.As(err, &apiErr) && apiErr.StatusCode == 403 {
+ reason := apiErr.Message
+ if reason == "" {
+ reason = apiErr.Name
+ }
+ return "Your PDS refused this change: " + reason + ". Log in again and approve all requested permissions.", true
+ }
+ var xrpcErr *xrpc.Error
+ if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == 403 {
+ return "Your PDS refused this change. Log in again and approve all requested permissions.", true
+ }
+ return "", false
+}
diff --git a/pkg/appview/handlers/oauth_errors_test.go b/pkg/appview/handlers/oauth_errors_test.go
new file mode 100644
index 0000000..686fe67
--- /dev/null
+++ b/pkg/appview/handlers/oauth_errors_test.go
@@ -0,0 +1,65 @@
+package handlers
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/bluesky-social/indigo/atproto/atclient"
+ "github.com/bluesky-social/indigo/xrpc"
+)
+
+func TestIsOAuthError(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {"nil", nil, false},
+ {"canceled", fmt.Errorf("putRecord: %w", context.Canceled), false},
+ {"api error 401", &atclient.APIError{StatusCode: 401}, true},
+ {"xrpc 401", &xrpc.Error{StatusCode: 401}, true},
+ {"api error InvalidGrant", &atclient.APIError{StatusCode: 400, Name: "InvalidGrant"}, true},
+ // A 403 is a missing permission on a live session, not a dead one.
+ {"api error 403 InsufficientScope", &atclient.APIError{StatusCode: 403, Name: "InsufficientScope"}, false},
+ {"xrpc 403", &xrpc.Error{StatusCode: 403}, false},
+ {"plain error", errors.New("boom"), false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isOAuthError(tt.err); got != tt.want {
+ t.Errorf("isOAuthError(%v) = %v, want %v", tt.err, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPDSDenied(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want bool
+ contains string
+ }{
+ {"tranquil InsufficientScope", fmt.Errorf("putRecord failed: %w", &atclient.APIError{StatusCode: 403, Name: "InsufficientScope", Message: "Insufficient scope to create records in io.atcr.sailor.star"}), true, "io.atcr.sailor.star"},
+ {"reference ScopeMissingError", &atclient.APIError{StatusCode: 403, Name: "ScopeMissingError", Message: `Missing required scope "repo:io.atcr.sailor.star?action=create"`}, true, "repo:io.atcr.sailor.star"},
+ {"name only", &atclient.APIError{StatusCode: 403, Name: "Forbidden"}, true, "Forbidden"},
+ {"xrpc 403", &xrpc.Error{StatusCode: 403}, true, "refused"},
+ {"401 is not a denial", &atclient.APIError{StatusCode: 401, Name: "InvalidToken"}, false, ""},
+ {"500", &atclient.APIError{StatusCode: 500}, false, ""},
+ {"plain", errors.New("boom"), false, ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ msg, ok := pdsDenied(tt.err)
+ if ok != tt.want {
+ t.Fatalf("pdsDenied ok = %v, want %v", ok, tt.want)
+ }
+ if !strings.Contains(msg, tt.contains) {
+ t.Errorf("message %q should contain %q", msg, tt.contains)
+ }
+ })
+ }
+}
diff --git a/pkg/appview/handlers/oauth_pages.go b/pkg/appview/handlers/oauth_pages.go
new file mode 100644
index 0000000..cb2d19f
--- /dev/null
+++ b/pkg/appview/handlers/oauth_pages.go
@@ -0,0 +1,73 @@
+package handlers
+
+import (
+ "bytes"
+ "net/http"
+
+ "atcr.io/pkg/auth/oauth"
+)
+
+// OAuthPages renders the browser-facing pages of the OAuth authorize and
+// callback endpoints (pkg/auth/oauth.Server) in the site layout. It is set on
+// the server with SetPageRenderer; the server keeps ownership of the status
+// codes and falls back to its inline templates if a render fails.
+type OAuthPages struct {
+ BaseUIHandler
+}
+
+var _ oauth.PageRenderer = (*OAuthPages)(nil)
+
+func (h *OAuthPages) meta(title string) *PageMeta {
+ return NewPageMeta(title+" - "+h.ClientShortName, "").
+ WithRobots("noindex").
+ WithSiteName(h.ClientShortName)
+}
+
+func (h *OAuthPages) execute(name string, data any) ([]byte, error) {
+ var buf bytes.Buffer
+ if err := h.Templates.ExecuteTemplate(&buf, name, data); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// RenderOAuthError implements oauth.PageRenderer.
+func (h *OAuthPages) RenderOAuthError(r *http.Request, message string) ([]byte, error) {
+ return h.execute("oauth-error", struct {
+ PageData
+ Meta *PageMeta
+ Message string
+ }{
+ PageData: NewPageData(r, &h.BaseUIHandler),
+ Meta: h.meta("Authorization failed"),
+ Message: message,
+ })
+}
+
+// RenderMissingScopes implements oauth.PageRenderer.
+func (h *OAuthPages) RenderMissingScopes(r *http.Request, missing []string, retryURL string) ([]byte, error) {
+ return h.execute("oauth-missing-scopes", struct {
+ PageData
+ Meta *PageMeta
+ Missing []string
+ RetryURL string
+ }{
+ PageData: NewPageData(r, &h.BaseUIHandler),
+ Meta: h.meta("Missing permissions"),
+ Missing: missing,
+ RetryURL: retryURL,
+ })
+}
+
+// RenderAuthorized implements oauth.PageRenderer.
+func (h *OAuthPages) RenderAuthorized(r *http.Request, handle string) ([]byte, error) {
+ return h.execute("oauth-authorized", struct {
+ PageData
+ Meta *PageMeta
+ Handle string
+ }{
+ PageData: NewPageData(r, &h.BaseUIHandler),
+ Meta: h.meta("Authorization successful"),
+ Handle: handle,
+ })
+}
diff --git a/pkg/appview/handlers/oauth_pages_test.go b/pkg/appview/handlers/oauth_pages_test.go
new file mode 100644
index 0000000..44dae19
--- /dev/null
+++ b/pkg/appview/handlers/oauth_pages_test.go
@@ -0,0 +1,113 @@
+package handlers_test
+
+import (
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "atcr.io/pkg/appview"
+ "atcr.io/pkg/appview/handlers"
+ _ "atcr.io/themes/seamark"
+)
+
+// TestOAuthPages_Render renders each OAuth page in the site layout, for both
+// the default templates and the seamark theme overlay. Set
+// ATCR_OAUTH_PAGES_DUMP=
to write the HTML out for inspection.
+func TestOAuthPages_Render(t *testing.T) {
+ seamark, err := appview.LookupTheme("seamark")
+ if err != nil {
+ t.Fatalf("lookup seamark theme: %v", err)
+ }
+
+ themes := []struct {
+ name string
+ overrides *appview.BrandingOverrides
+ shortName string
+ }{
+ {"default", nil, "ATCR"},
+ {"seamark", seamark, "Seamark"},
+ }
+
+ for _, theme := range themes {
+ t.Run(theme.name, func(t *testing.T) {
+ tmpl, err := appview.Templates(theme.overrides)
+ if err != nil {
+ t.Fatalf("load templates: %v", err)
+ }
+ pages := &handlers.OAuthPages{BaseUIHandler: handlers.BaseUIHandler{
+ Templates: tmpl,
+ RegistryURL: "registry.example",
+ SiteURL: "site.example",
+ ClientName: "Test Registry",
+ ClientShortName: theme.shortName,
+ }}
+ req := httptest.NewRequest("GET", "/auth/oauth/callback", nil)
+
+ cases := []struct {
+ page string
+ render func() ([]byte, error)
+ want []string
+ }{
+ {"error", func() ([]byte, error) {
+ return pages.RenderOAuthError(req, "OAuth error: access_denied - ")
+ }, []string{
+ "Authorization failed",
+ "OAuth error: access_denied - <script>nope</script>",
+ `href="/"`,
+ }},
+ {"missing-scopes", func() ([]byte, error) {
+ return pages.RenderMissingScopes(req, []string{"repo:io.atcr.manifest", "blob:*/*"}, "/auth/oauth/authorize?handle=did%3Aplc%3Aabc")
+ }, []string{
+ "Some permissions weren't granted",
+ ">repo:io.atcr.manifest",
+ ">blob:*/*",
+ `href="/auth/oauth/authorize?handle=did%3Aplc%3Aabc"`,
+ "Try again",
+ "Return to home",
+ "didn't grant everything " + theme.shortName + " needs",
+ }},
+ {"authorized", func() ([]byte, error) {
+ return pages.RenderAuthorized(req, "alice.test")
+ }, []string{
+ "Authorization successful",
+ "alice.test",
+ `content="3;url=/settings"`,
+ }},
+ }
+
+ for _, c := range cases {
+ t.Run(c.page, func(t *testing.T) {
+ out, err := c.render()
+ if err != nil {
+ t.Fatalf("render: %v", err)
+ }
+ body := string(out)
+
+ // Site chrome: shared head, simple nav, footer.
+ for _, chrome := range []string{``, `id="main-content"`, `class="navbar`, "", `name="robots" content="noindex"`} {
+ if !strings.Contains(body, chrome) {
+ t.Errorf("missing site chrome %q", chrome)
+ }
+ }
+ for _, w := range c.want {
+ if !strings.Contains(body, w) {
+ t.Errorf("missing %q", w)
+ }
+ }
+ if strings.Contains(body, "—") {
+ t.Error("page contains an em dash")
+ }
+
+ if dir := os.Getenv("ATCR_OAUTH_PAGES_DUMP"); dir != "" {
+ name := filepath.Join(dir, theme.name+"-"+c.page+".html")
+ if err := os.WriteFile(name, out, 0o644); err != nil {
+ t.Fatalf("dump: %v", err)
+ }
+ }
+ })
+ }
+ })
+ }
+}
diff --git a/pkg/appview/handlers/repo_editor.go b/pkg/appview/handlers/repo_editor.go
index 91d355c..e083381 100644
--- a/pkg/appview/handlers/repo_editor.go
+++ b/pkg/appview/handlers/repo_editor.go
@@ -80,6 +80,10 @@ func (h *SaveRepoPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
return
}
+ if msg, ok := pdsDenied(err); ok {
+ http.Error(w, msg, http.StatusForbidden)
+ return
+ }
http.Error(w, "Failed to save description", http.StatusInternalServerError)
return
}
diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go
index 166c178..0e9381a 100644
--- a/pkg/appview/routes/routes.go
+++ b/pkg/appview/routes/routes.go
@@ -53,47 +53,15 @@ type UIDependencies struct {
CredHelper installscript.Brand // Credential helper brand (credHelpers value, binary name, config dir)
}
+// NewOAuthPages returns the site-styled renderer for the OAuth endpoints'
+// browser pages, for oauth.Server.SetPageRenderer.
+func NewOAuthPages(deps UIDependencies) *uihandlers.OAuthPages {
+ return &uihandlers.OAuthPages{BaseUIHandler: newBaseUIHandler(deps)}
+}
+
// RegisterUIRoutes registers all web UI and API routes on the provided router
func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
- // SiteURL is always derived from BaseURL (the website domain)
- siteURL := trimRegistryURL(deps.BaseURL)
-
- // RegistryURL is the registry domain if configured, otherwise same as SiteURL
- registryURL := siteURL
- if deps.RegistryDomain != "" {
- registryURL = deps.RegistryDomain
- }
-
- // Create base with all dependencies - handlers just embed this
- base := uihandlers.BaseUIHandler{
- Templates: deps.Templates,
- RegistryURL: registryURL,
- RegistryDomains: deps.RegistryDomains,
- SiteURL: siteURL,
- DB: deps.Database,
- ReadOnlyDB: deps.ReadOnlyDB,
- Refresher: deps.Refresher,
- HealthChecker: deps.HealthChecker,
- ReadmeFetcher: deps.ReadmeFetcher,
- Directory: deps.OAuthClientApp.Dir,
- OAuthClientApp: deps.OAuthClientApp,
- SessionStore: deps.SessionStore,
- DeviceStore: deps.DeviceStore,
- OAuthStore: deps.OAuthStore,
- BillingManager: deps.BillingManager,
- WebhookDispatcher: deps.WebhookDispatcher,
- DefaultHoldDID: deps.DefaultHoldDID,
- ManagedHolds: deps.ManagedHolds,
- CompanyName: deps.LegalConfig.CompanyName,
- Jurisdiction: deps.LegalConfig.Jurisdiction,
- ClientName: deps.ClientName,
- ClientShortName: deps.ClientShortName,
- BillingEnabled: deps.BillingManager != nil && deps.BillingManager.Enabled(),
- AIAdvisorEnabled: deps.BillingManager != nil && deps.BillingManager.Enabled() && deps.ClaudeAPIKey != "",
- SourceURL: deps.SourceURL,
- BlueskyProfile: deps.BlueskyProfile,
- CredHelper: deps.CredHelper,
- }
+ base := newBaseUIHandler(deps)
// OAuth login routes (public)
router.Get("/auth/oauth/login", (&uihandlers.LoginHandler{BaseUIHandler: base}).ServeHTTP)
@@ -275,6 +243,49 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
).ServeHTTP)
}
+// newBaseUIHandler builds the dependencies every UI handler embeds.
+func newBaseUIHandler(deps UIDependencies) uihandlers.BaseUIHandler {
+ // SiteURL is always derived from BaseURL (the website domain)
+ siteURL := trimRegistryURL(deps.BaseURL)
+
+ // RegistryURL is the registry domain if configured, otherwise same as SiteURL
+ registryURL := siteURL
+ if deps.RegistryDomain != "" {
+ registryURL = deps.RegistryDomain
+ }
+
+ // Create base with all dependencies - handlers just embed this
+ return uihandlers.BaseUIHandler{
+ Templates: deps.Templates,
+ RegistryURL: registryURL,
+ RegistryDomains: deps.RegistryDomains,
+ SiteURL: siteURL,
+ DB: deps.Database,
+ ReadOnlyDB: deps.ReadOnlyDB,
+ Refresher: deps.Refresher,
+ HealthChecker: deps.HealthChecker,
+ ReadmeFetcher: deps.ReadmeFetcher,
+ Directory: deps.OAuthClientApp.Dir,
+ OAuthClientApp: deps.OAuthClientApp,
+ SessionStore: deps.SessionStore,
+ DeviceStore: deps.DeviceStore,
+ OAuthStore: deps.OAuthStore,
+ BillingManager: deps.BillingManager,
+ WebhookDispatcher: deps.WebhookDispatcher,
+ DefaultHoldDID: deps.DefaultHoldDID,
+ ManagedHolds: deps.ManagedHolds,
+ CompanyName: deps.LegalConfig.CompanyName,
+ Jurisdiction: deps.LegalConfig.Jurisdiction,
+ ClientName: deps.ClientName,
+ ClientShortName: deps.ClientShortName,
+ BillingEnabled: deps.BillingManager != nil && deps.BillingManager.Enabled(),
+ AIAdvisorEnabled: deps.BillingManager != nil && deps.BillingManager.Enabled() && deps.ClaudeAPIKey != "",
+ SourceURL: deps.SourceURL,
+ BlueskyProfile: deps.BlueskyProfile,
+ CredHelper: deps.CredHelper,
+ }
+}
+
// CORSMiddleware returns a middleware that sets CORS headers for API endpoints
func CORSMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
diff --git a/pkg/appview/server.go b/pkg/appview/server.go
index 9091c0a..90422ab 100644
--- a/pkg/appview/server.go
+++ b/pkg/appview/server.go
@@ -400,7 +400,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
}
// Register UI routes
- routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{
+ uiDeps := routes.UIDependencies{
Database: s.Database,
ReadOnlyDB: s.ReadOnlyDB,
SessionStore: s.SessionStore,
@@ -428,7 +428,8 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
CompanyName: cfg.Legal.CompanyName,
Jurisdiction: cfg.Legal.Jurisdiction,
},
- })
+ }
+ routes.RegisterUIRoutes(mainRouter, uiDeps)
// Register Stripe webhook route (if billing enabled)
s.BillingManager.RegisterRoutes(mainRouter)
@@ -439,6 +440,9 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
if s.SessionStore != nil {
s.OAuthServer.SetUISessionStore(s.SessionStore)
}
+ // Draw the OAuth error, missing-permissions and success pages in the
+ // site layout instead of the package's bare fallback HTML.
+ s.OAuthServer.SetPageRenderer(routes.NewOAuthPages(uiDeps))
// Register OAuth post-auth callback (closure captures s for hook dispatch)
s.OAuthServer.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error {
diff --git a/pkg/appview/storage/manifest_store.go b/pkg/appview/storage/manifest_store.go
index e4ca525..066dd5d 100644
--- a/pkg/appview/storage/manifest_store.go
+++ b/pkg/appview/storage/manifest_store.go
@@ -16,25 +16,40 @@ import (
"atcr.io/pkg/appview/holdpurge"
"atcr.io/pkg/appview/readme"
"atcr.io/pkg/atproto"
+ "github.com/bluesky-social/indigo/atproto/atclient"
"github.com/distribution/distribution/v3"
"github.com/distribution/distribution/v3/registry/api/errcode"
"github.com/opencontainers/go-digest"
)
-// rateLimitToErrcode converts an upstream PDS rate-limit error into a
-// distribution errcode.Error (HTTP 429). When ctx contains a
-// RetryAfterCarrier, also stashes the retry-after duration so HTTP
-// middleware can emit a Retry-After response header. Returns the original
-// error untouched when it isn't a rate-limit error.
-func rateLimitToErrcode(ctx context.Context, err error) error {
+// pdsErrorToErrcode converts an upstream PDS error the client can act on into
+// a distribution errcode.Error, and returns any other error untouched.
+//
+// A rate limit becomes a 429. When ctx contains a RetryAfterCarrier, the
+// retry-after duration is stashed too so HTTP middleware can emit a
+// Retry-After response header.
+//
+// A 403 becomes DENIED, carrying the PDS's own message. It almost always means
+// the session wasn't granted this write (InsufficientScope), and left alone it
+// reaches the client as a 500 "unknown error" that explains nothing.
+func pdsErrorToErrcode(ctx context.Context, err error) error {
var rl *atproto.RateLimitError
- if !errors.As(err, &rl) {
- return err
+ if errors.As(err, &rl) {
+ if rl.RetryAfter > 0 {
+ SetRetryAfter(ctx, rl.RetryAfter)
+ }
+ return errcode.ErrorCodeTooManyRequests.WithMessage(rl.Error())
}
- if rl.RetryAfter > 0 {
- SetRetryAfter(ctx, rl.RetryAfter)
+ var apiErr *atclient.APIError
+ if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden {
+ msg := apiErr.Message
+ if msg == "" {
+ msg = apiErr.Name
+ }
+ return errcode.ErrorCodeDenied.WithMessage(
+ "your PDS refused the write: " + msg + ". Log in to ATCR again and approve all requested permissions.")
}
- return errcode.ErrorCodeTooManyRequests.WithMessage(rl.Error())
+ return err
}
// pullDedup deduplicates pull notifications per puller+owner+repo within a 5-minute window.
@@ -200,7 +215,7 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest,
// Upload manifest as blob to PDS
blobRef, err := s.ctx.ATProtoClient.UploadBlob(ctx, payload, mediaType)
if err != nil {
- if rl := rateLimitToErrcode(ctx, err); rl != err {
+ if rl := pdsErrorToErrcode(ctx, err); rl != err {
return "", rl
}
return "", fmt.Errorf("failed to upload manifest blob: %w", err)
@@ -287,7 +302,7 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest,
rkey := digestToRKey(dgst)
_, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.ManifestCollection, rkey, manifestRecord)
if err != nil {
- if rl := rateLimitToErrcode(ctx, err); rl != err {
+ if rl := pdsErrorToErrcode(ctx, err); rl != err {
return "", rl
}
return "", fmt.Errorf("failed to store manifest record in ATProto: %w", err)
@@ -316,7 +331,7 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest,
tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String(), mediaType)
_, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.TagCollection, tagRKey, tagRecord)
if err != nil {
- if rl := rateLimitToErrcode(ctx, err); rl != err {
+ if rl := pdsErrorToErrcode(ctx, err); rl != err {
return "", rl
}
return "", fmt.Errorf("failed to store tag in ATProto: %w", err)
@@ -462,7 +477,7 @@ func (s *ManifestStore) Delete(ctx context.Context, dgst digest.Digest) error {
for _, tag := range inRepo {
tagRKey := atproto.RepositoryTagToRKey(s.ctx.Repository, tag)
if err := s.ctx.ATProtoClient.DeleteRecord(ctx, atproto.TagCollection, tagRKey); err != nil {
- if rl := rateLimitToErrcode(ctx, err); rl != err {
+ if rl := pdsErrorToErrcode(ctx, err); rl != err {
return rl
}
return err
@@ -480,7 +495,7 @@ func (s *ManifestStore) Delete(ctx context.Context, dgst digest.Digest) error {
rkey := digestToRKey(dgst)
if err := s.ctx.ATProtoClient.DeleteRecord(ctx, atproto.ManifestCollection, rkey); err != nil {
- if rl := rateLimitToErrcode(ctx, err); rl != err {
+ if rl := pdsErrorToErrcode(ctx, err); rl != err {
return rl
}
return err
diff --git a/pkg/appview/storage/pds_error_test.go b/pkg/appview/storage/pds_error_test.go
new file mode 100644
index 0000000..fd78faf
--- /dev/null
+++ b/pkg/appview/storage/pds_error_test.go
@@ -0,0 +1,52 @@
+package storage
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/bluesky-social/indigo/atproto/atclient"
+ "github.com/distribution/distribution/v3/registry/api/errcode"
+)
+
+// TestPDSErrorToErrcode_InsufficientScopeIsDenied pins the fix for a push that
+// failed at putRecord with 403 InsufficientScope and reached Docker as
+// "500 unknown: unknown error".
+func TestPDSErrorToErrcode_InsufficientScopeIsDenied(t *testing.T) {
+ pdsErr := fmt.Errorf("putRecord failed: %w", &atclient.APIError{
+ StatusCode: http.StatusForbidden,
+ Name: "InsufficientScope",
+ Message: "Insufficient scope to create records in io.atcr.manifest",
+ })
+
+ got := pdsErrorToErrcode(context.Background(), pdsErr)
+
+ var ec errcode.Error
+ if !errors.As(got, &ec) {
+ t.Fatalf("got %T (%v), want errcode.Error", got, got)
+ }
+ if ec.Code != errcode.ErrorCodeDenied {
+ t.Errorf("code = %v, want DENIED", ec.Code)
+ }
+ if ec.Code.Descriptor().HTTPStatusCode != http.StatusForbidden {
+ t.Errorf("status = %d, want 403", ec.Code.Descriptor().HTTPStatusCode)
+ }
+ if !strings.Contains(ec.Message, "io.atcr.manifest") {
+ t.Errorf("message %q should carry the PDS's reason", ec.Message)
+ }
+}
+
+func TestPDSErrorToErrcode_OtherErrorsUntouched(t *testing.T) {
+ for _, err := range []error{
+ errors.New("boom"),
+ &atclient.APIError{StatusCode: http.StatusInternalServerError, Name: "InternalServerError"},
+ &atclient.APIError{StatusCode: http.StatusUnauthorized, Name: "InvalidToken"},
+ } {
+ if got := pdsErrorToErrcode(context.Background(), err); got != err {
+ t.Errorf("pdsErrorToErrcode(%v) = %v, want it untouched", err, got)
+ }
+ }
+}
diff --git a/pkg/appview/templates/pages/oauth.html b/pkg/appview/templates/pages/oauth.html
new file mode 100644
index 0000000..36b79a0
--- /dev/null
+++ b/pkg/appview/templates/pages/oauth.html
@@ -0,0 +1,121 @@
+{{/*
+ Pages rendered for the OAuth authorize/callback endpoints, which live in
+ pkg/auth/oauth. That package owns the status codes and falls back to its
+ own inline HTML when no appview renderer is set; these templates are the
+ site-styled versions, filled in by handlers.OAuthPages.
+ Plain language only on these screens (no maritime copy).
+*/}}
+
+{{ define "oauth-error" }}
+
+
+
+ {{ template "head" . }}
+ {{ template "meta" .Meta }}
+
+
+ {{ template "nav-simple" . }}
+
+
+
+
Authorization failed
+
+
+ {{ icon "circle-x" "size-5" }}
+ {{ .Message }}
+
+
+
+
+
+
+ {{ template "footer" . }}
+
+
+{{ end }}
+
+{{ define "oauth-missing-scopes" }}
+
+
+
+ {{ template "head" . }}
+ {{ template "meta" .Meta }}
+
+
+ {{ template "nav-simple" . }}
+
+
+
+
Some permissions weren't granted
+
+
+ {{ icon "triangle-alert" "size-5" }}
+ Your PDS signed you in, but didn't grant everything {{ .ClientShortName }} needs to push and manage images. You haven't been logged in.
+
+
+
Missing
+
+ {{ range .Missing }}
+ {{ . }}
+ {{ end }}
+
+
+
+ If your PDS let you untick permissions, try again and approve all of them.
+ If a permission wasn't shown to you at all, your PDS may not support it yet.
+ Updating the PDS usually fixes this.
+
+
+
+
+
+
+ {{ template "footer" . }}
+
+
+{{ end }}
+
+{{ define "oauth-authorized" }}
+
+
+
+ {{ template "head" . }}
+ {{ template "meta" .Meta }}
+
+
+
+ {{ template "nav-simple" . }}
+
+
+
+
Authorization successful
+
+
+ {{ icon "check-circle" "size-5" }}
+ You have authorized {{ .ClientShortName }} to access your account: {{ .Handle }}
+
+
+
+ Redirecting to the settings page to generate your API key. If nothing happens,
+ go to settings.
+
+
+
Next steps
+
+ - Generate an API key on the settings page
+ - Copy the API key (it's shown once)
+ - Use it with:
docker login {{ .RegistryURL }} -u {{ .Handle }} -p [your-api-key]
+
+
+
+
+ {{ template "footer" . }}
+
+
+{{ end }}
diff --git a/pkg/auth/oauth/client.go b/pkg/auth/oauth/client.go
index a4e01d0..a9a92f3 100644
--- a/pkg/auth/oauth/client.go
+++ b/pkg/auth/oauth/client.go
@@ -169,38 +169,6 @@ func GetDefaultScopes(did string) []string {
}
}
-// ScopesMatch checks if two scope lists are equivalent (order-independent)
-// Returns true if both lists contain the same scopes, regardless of order.
-// Expands any "include:" prefixed scopes in the desired list before comparing,
-// since the PDS returns expanded scopes in the stored session.
-func ScopesMatch(stored, desired []string) bool {
- // Expand any include: scopes in desired before comparing
- expandedDesired := ExpandIncludeScopes(desired)
-
- // Handle nil/empty cases
- if len(stored) == 0 && len(expandedDesired) == 0 {
- return true
- }
- if len(stored) != len(expandedDesired) {
- return false
- }
-
- // Build map of desired scopes for O(1) lookup
- desiredMap := make(map[string]bool, len(expandedDesired))
- for _, scope := range expandedDesired {
- desiredMap[scope] = true
- }
-
- // Check if all stored scopes exist in desired
- for _, scope := range stored {
- if !desiredMap[scope] {
- return false
- }
- }
-
- return true
-}
-
// isLocalhost checks if a base URL is a localhost address
func isLocalhost(baseURL string) bool {
return strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost")
@@ -483,8 +451,12 @@ func IsSessionInvalidError(err error) bool {
// have fixed. A genuinely dead session still gets caught by the 401
// status check above. isAuthError (below) omits it for the same reason;
// the two classifiers must agree about the same condition.
+ // Also absent: InsufficientScope. It means the session is alive but
+ // wasn't granted this permission, and signing in again at the same
+ // PDS gets the same grant, so deleting it only loops the user through
+ // login. The caller should surface it as a 403 instead.
switch apiErr.Name {
- case "InvalidToken", "InvalidGrant", "InsufficientScope":
+ case "InvalidToken", "InvalidGrant":
return true
}
}
@@ -501,7 +473,6 @@ func IsSessionInvalidError(err error) bool {
errStr := strings.ToLower(err.Error())
return strings.Contains(errStr, "invalid_grant") ||
strings.Contains(errStr, "invalid_token") ||
- strings.Contains(errStr, "insufficient_scope") ||
strings.Contains(errStr, "token expired")
}
@@ -529,7 +500,8 @@ func isAuthError(err error) bool {
if apiErr.StatusCode == 401 {
return true
}
- if apiErr.Name == "InvalidToken" || apiErr.Name == "InsufficientScope" {
+ // Not InsufficientScope: see IsSessionInvalidError.
+ if apiErr.Name == "InvalidToken" {
return true
}
}
@@ -538,7 +510,6 @@ func isAuthError(err error) bool {
// appear in digests or URIs
errStr := strings.ToLower(err.Error())
return strings.Contains(errStr, "invalid_token") ||
- strings.Contains(errStr, "insufficient_scope") ||
strings.Contains(errStr, "token expired")
}
@@ -566,15 +537,15 @@ func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.Clien
return nil, fmt.Errorf("no session found for DID: %s", did)
}
- // Log scope differences for debugging, but don't delete session
- // The PDS will reject requests if scopes are insufficient
- // (Permission-sets get expanded by PDS, so exact matching doesn't work)
- desiredScopes := r.clientApp.Config.Scopes
- if !ScopesMatch(sessionData.Scopes, desiredScopes) {
- slog.Debug("Session scopes differ from desired (may be permission-set expansion)",
+ // A session missing a scope is refused at login and evicted on boot, so
+ // one reaching here predates that check or the desired set changed since
+ // the last boot. Log it but carry on: the PDS rejects whatever the session
+ // can't do, and that surfaces as a 403 rather than a sign-out.
+ if missing := MissingScopes(sessionData.Scopes, r.clientApp.Config.Scopes); len(missing) > 0 {
+ slog.Warn("Session is missing scopes",
+ "component", "oauth/refresher",
"did", did,
- "storedScopes", sessionData.Scopes,
- "desiredScopes", desiredScopes)
+ "missing", missing)
}
// Resume session
diff --git a/pkg/auth/oauth/client_test.go b/pkg/auth/oauth/client_test.go
index 84f50f7..1c597cb 100644
--- a/pkg/auth/oauth/client_test.go
+++ b/pkg/auth/oauth/client_test.go
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
+ "slices"
+ "strings"
"testing"
"github.com/bluesky-social/indigo/atproto/atclient"
@@ -55,68 +57,146 @@ func TestNewClientAppWithCustomScopes(t *testing.T) {
}
}
-func TestScopesMatch(t *testing.T) {
+func TestMissingScopes(t *testing.T) {
+ fullApp := "repo?collection=io.atcr.manifest&collection=io.atcr.repo.page&collection=io.atcr.sailor.profile&collection=io.atcr.sailor.star&collection=io.atcr.tag"
+
tests := []struct {
- name string
- stored []string
- desired []string
- expected bool
+ name string
+ granted []string
+ desired []string
+ missing []string
}{
{
- name: "exact match",
- stored: []string{"atproto", "blob:image/png"},
- desired: []string{"atproto", "blob:image/png"},
- expected: true,
+ name: "exact match",
+ granted: []string{"atproto", "blob:image/png"},
+ desired: []string{"atproto", "blob:image/png"},
},
{
- name: "different order",
- stored: []string{"blob:image/png", "atproto"},
- desired: []string{"atproto", "blob:image/png"},
- expected: true,
+ name: "different order",
+ granted: []string{"blob:image/png", "atproto"},
+ desired: []string{"atproto", "blob:image/png"},
},
{
- name: "missing scope in stored",
- stored: []string{"atproto"},
- desired: []string{"atproto", "blob:image/png"},
- expected: false,
+ name: "both empty",
+ granted: nil,
+ desired: []string{},
},
{
- name: "extra scope in stored",
- stored: []string{"atproto", "blob:image/png", "extra"},
- desired: []string{"atproto", "blob:image/png"},
- expected: false,
+ name: "extra grants are fine",
+ granted: []string{"atproto", "blob:image/png", "repo:app.bsky.feed.post", "extra"},
+ desired: []string{"atproto", "blob:image/png"},
},
{
- name: "both empty",
- stored: []string{},
- desired: []string{},
- expected: true,
+ name: "missing atproto",
+ granted: []string{"blob:image/png"},
+ desired: []string{"atproto", "blob:image/png"},
+ missing: []string{"atproto"},
},
{
- name: "nil vs empty",
- stored: nil,
- desired: []string{},
- expected: true,
+ name: "missing blob",
+ granted: []string{"atproto"},
+ desired: []string{"atproto", "blob:image/png"},
+ missing: []string{"blob:image/png"},
},
{
- name: "completely different",
- stored: []string{"foo", "bar"},
- desired: []string{"baz", "qux"},
- expected: false,
+ name: "blob wildcard covers a type",
+ granted: []string{"atproto", "blob:image/*"},
+ desired: []string{"atproto", "blob:image/png", "blob:image/*"},
+ },
+ {
+ name: "blob */* covers anything",
+ granted: []string{"atproto", "blob:*/*"},
+ desired: []string{"atproto", "blob:application/vnd.oci.image.manifest.v1+json"},
+ },
+ {
+ name: "blob wildcard of another type does not cover",
+ granted: []string{"atproto", "blob:image/*"},
+ desired: []string{"atproto", "blob:application/json"},
+ missing: []string{"blob:application/json"},
+ },
+ {
+ name: "include expanded by the PDS",
+ granted: []string{"atproto", fullApp},
+ desired: []string{"atproto", "include:io.atcr.authFullApp"},
+ },
+ {
+ name: "include echoed back unexpanded",
+ granted: []string{"atproto", "include:io.atcr.authFullApp"},
+ desired: []string{"atproto", "include:io.atcr.authFullApp"},
+ },
+ {
+ name: "include expanded into one scope per collection, any order",
+ granted: []string{"atproto",
+ "repo:io.atcr.tag", "repo:io.atcr.sailor.star", "repo:io.atcr.manifest",
+ "repo:io.atcr.sailor.profile", "repo:io.atcr.repo.page"},
+ desired: []string{"atproto", "include:io.atcr.authFullApp"},
+ },
+ {
+ // The tranquil.farm case: the consent screen dropped the manifest
+ // collection, so the push failed at putRecord.
+ name: "collection missing from the permission set",
+ granted: []string{"atproto",
+ "repo?collection=io.atcr.repo.page&collection=io.atcr.sailor.profile&collection=io.atcr.sailor.star&collection=io.atcr.tag"},
+ desired: []string{"atproto", "include:io.atcr.authFullApp"},
+ missing: []string{"repo:io.atcr.manifest?action=create&action=update&action=delete"},
+ },
+ {
+ name: "action subset granted",
+ granted: []string{"atproto", "repo:io.atcr.manifest?action=create"},
+ desired: []string{"atproto", "repo:io.atcr.manifest"},
+ missing: []string{"repo:io.atcr.manifest?action=update&action=delete"},
+ },
+ {
+ name: "wildcard collection covers",
+ granted: []string{"atproto", "repo:*"},
+ desired: []string{"atproto", "include:io.atcr.authFullApp"},
+ },
+ {
+ name: "transition:generic covers repo, blob and rpc",
+ granted: []string{"atproto", "transition:generic"},
+ desired: []string{"atproto", "include:io.atcr.authFullApp", "blob:image/*", "rpc:com.atproto.repo.getRecord?aud=*"},
+ },
+ {
+ name: "rpc match",
+ granted: []string{"atproto", "rpc:com.atproto.repo.getRecord?aud=*"},
+ desired: []string{"atproto", "rpc:com.atproto.repo.getRecord?aud=*"},
+ },
+ {
+ name: "rpc with narrower aud does not cover aud=*",
+ granted: []string{"atproto", "rpc:com.atproto.repo.getRecord?aud=did:web:hold.example.com%23atcr_hold"},
+ desired: []string{"atproto", "rpc:com.atproto.repo.getRecord?aud=*"},
+ missing: []string{"rpc:com.atproto.repo.getRecord?aud=*"},
+ },
+ {
+ name: "default scopes fully granted",
+ granted: append([]string{fullApp}, withoutInclude(GetDefaultScopes("*"))...),
+ desired: GetDefaultScopes("*"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := ScopesMatch(tt.stored, tt.desired)
- if result != tt.expected {
- t.Errorf("ScopesMatch(%v, %v) = %v, want %v",
- tt.stored, tt.desired, result, tt.expected)
+ got := MissingScopes(tt.granted, tt.desired)
+ if !slices.Equal(got, tt.missing) {
+ t.Errorf("MissingScopes(%v, %v) = %v, want %v", tt.granted, tt.desired, got, tt.missing)
+ }
+ if ScopesCover(tt.granted, tt.desired) != (len(tt.missing) == 0) {
+ t.Errorf("ScopesCover disagrees with MissingScopes")
}
})
}
}
+func withoutInclude(scopes []string) []string {
+ var out []string
+ for _, s := range scopes {
+ if !strings.HasPrefix(s, "include:") {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
// ----------------------------------------------------------------------------
// Session Management (Refresher) Tests
// ----------------------------------------------------------------------------
@@ -198,6 +278,11 @@ func TestIsSessionInvalidError(t *testing.T) {
{"plain invalid_token string", errors.New("auth server request failed (HTTP 401): invalid_token"), true},
{"connection refused", errors.New(`Post "https://pds.example.com/oauth/token": dial tcp: connection refused`), false},
{"generic 500", errors.New("token refresh failed (HTTP 500): server exploded"), false},
+ // A live session the PDS didn't grant this permission to. Deleting it
+ // signed the user out mid-push, and logging in again at the same PDS
+ // got the same grant, so they looped (tranquil.farm, 2026-09-21).
+ {"api error 403 InsufficientScope is alive", &atclient.APIError{StatusCode: 403, Name: "InsufficientScope", Message: "Insufficient scope to create records in io.atcr.manifest"}, false},
+ {"wrapped InsufficientScope string", errors.New("putRecord failed: API request failed (HTTP 403): InsufficientScope: insufficient_scope"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -207,3 +292,31 @@ func TestIsSessionInvalidError(t *testing.T) {
})
}
}
+
+// TestIsAuthError covers the gate DoWithSession uses to delete a session after
+// a failed PDS call. It must agree with IsSessionInvalidError.
+func TestIsAuthError(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {"nil", nil, false},
+ {"canceled", fmt.Errorf("putRecord failed: %w", context.Canceled), false},
+ {"api error 401", &atclient.APIError{StatusCode: 401}, true},
+ {"api error InvalidToken", &atclient.APIError{StatusCode: 400, Name: "InvalidToken"}, true},
+ {"api error 403 InsufficientScope", fmt.Errorf("putRecord failed: %w", &atclient.APIError{StatusCode: 403, Name: "InsufficientScope", Message: "Insufficient scope to create records in io.atcr.manifest"}), false},
+ {"insufficient_scope string", errors.New("request failed: insufficient_scope"), false},
+ {"plain invalid_token string", errors.New("auth server request failed (HTTP 401): invalid_token"), true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isAuthError(tt.err); got != tt.want {
+ t.Errorf("isAuthError(%v) = %v, want %v", tt.err, got, tt.want)
+ }
+ if tt.err != nil && isAuthError(tt.err) != IsSessionInvalidError(tt.err) {
+ t.Errorf("isAuthError and IsSessionInvalidError disagree on %v", tt.err)
+ }
+ })
+ }
+}
diff --git a/pkg/auth/oauth/scopes.go b/pkg/auth/oauth/scopes.go
new file mode 100644
index 0000000..23df09f
--- /dev/null
+++ b/pkg/auth/oauth/scopes.go
@@ -0,0 +1,189 @@
+package oauth
+
+import (
+ "slices"
+ "sort"
+ "strings"
+
+ atauth "github.com/bluesky-social/indigo/atproto/auth"
+)
+
+// repoActions are the actions a repo permission grants when it names none.
+var repoActions = []string{"create", "update", "delete"}
+
+// MissingScopes reports which of the desired scopes the granted set does not
+// cover. It is an "at least" check: extra grants are ignored, and a grant
+// counts if it is broader than what was asked for (a wildcard collection, a
+// `type/*` blob accept, an rpc aud of `*`, or the legacy transition:generic).
+//
+// Scopes are compared by what they grant, not by how they are spelled. A PDS
+// may expand a permission-set into one repo scope or several, list collections
+// in any order, or echo the include: back unexpanded, and none of those should
+// count as a missing permission. Both sides go through ExpandIncludeScopes
+// first; a desired scope that still doesn't parse is matched by exact string.
+//
+// Each missing entry is rendered as a scope string narrowed to what is
+// actually absent, e.g. "repo:io.atcr.manifest?action=create", so it can be
+// shown to the user as-is.
+func MissingScopes(granted, desired []string) []string {
+ g := parseGranted(ExpandIncludeScopes(granted))
+
+ var missing []string
+ missingRepo := map[string][]string{} // collection -> actions
+
+ for _, scope := range ExpandIncludeScopes(desired) {
+ if scope == "" {
+ continue
+ }
+ if scope == "atproto" {
+ if !g.raw["atproto"] {
+ missing = append(missing, scope)
+ }
+ continue
+ }
+ perm, err := atauth.ParsePermissionString(scope)
+ if err != nil {
+ if !g.raw[scope] {
+ missing = append(missing, scope)
+ }
+ continue
+ }
+
+ switch perm.Resource {
+ case "repo":
+ actions := perm.Action
+ if len(actions) == 0 {
+ actions = repoActions
+ }
+ for _, coll := range perm.Collection {
+ for _, act := range actions {
+ if !g.coversRepo(coll, act) {
+ missingRepo[coll] = append(missingRepo[coll], act)
+ }
+ }
+ }
+ case "blob":
+ for _, accept := range perm.Accept {
+ if !g.coversBlob(accept) {
+ missing = append(missing, "blob:"+accept)
+ }
+ }
+ case "rpc":
+ for _, lxm := range perm.Endpoint {
+ if !g.coversRPC(lxm, perm.Audience) {
+ missing = append(missing, "rpc:"+lxm+"?aud="+perm.Audience)
+ }
+ }
+ default:
+ // include: that we couldn't expand, account:, identity: — nothing
+ // finer-grained to compare, so it has to be granted verbatim.
+ if !g.raw[scope] {
+ missing = append(missing, scope)
+ }
+ }
+ }
+
+ colls := make([]string, 0, len(missingRepo))
+ for coll := range missingRepo {
+ colls = append(colls, coll)
+ }
+ sort.Strings(colls)
+ for _, coll := range colls {
+ missing = append(missing, "repo:"+coll+"?action="+strings.Join(missingRepo[coll], "&action="))
+ }
+
+ return missing
+}
+
+// ScopesCover reports whether granted covers every desired scope. See
+// MissingScopes.
+func ScopesCover(granted, desired []string) bool {
+ return len(MissingScopes(granted, desired)) == 0
+}
+
+type grantedScopes struct {
+ raw map[string]bool
+ generic bool // transition:generic grants every repo, blob, and rpc permission
+ perms []atauth.Permission
+}
+
+func parseGranted(scopes []string) grantedScopes {
+ g := grantedScopes{raw: make(map[string]bool, len(scopes))}
+ for _, s := range scopes {
+ g.raw[s] = true
+ if s == "transition:generic" {
+ g.generic = true
+ continue
+ }
+ if p, err := atauth.ParsePermissionString(s); err == nil {
+ g.perms = append(g.perms, *p)
+ }
+ }
+ return g
+}
+
+func (g grantedScopes) coversRepo(coll, action string) bool {
+ if g.generic {
+ return true
+ }
+ for _, p := range g.perms {
+ if p.Resource != "repo" {
+ continue
+ }
+ if !slices.Contains(p.Collection, coll) && !slices.Contains(p.Collection, "*") {
+ continue
+ }
+ if len(p.Action) == 0 || slices.Contains(p.Action, action) {
+ return true
+ }
+ }
+ return false
+}
+
+func (g grantedScopes) coversBlob(accept string) bool {
+ if g.generic {
+ return true
+ }
+ for _, p := range g.perms {
+ if p.Resource != "blob" {
+ continue
+ }
+ for _, a := range p.Accept {
+ if mimeCovers(a, accept) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (g grantedScopes) coversRPC(lxm, aud string) bool {
+ if g.generic {
+ return true
+ }
+ for _, p := range g.perms {
+ if p.Resource != "rpc" {
+ continue
+ }
+ if p.Audience != aud && p.Audience != "*" {
+ continue
+ }
+ if slices.Contains(p.Endpoint, lxm) || slices.Contains(p.Endpoint, "*") {
+ return true
+ }
+ }
+ return false
+}
+
+// mimeCovers reports whether a granted blob accept pattern covers a wanted
+// one: an exact match, "*/*", or a "type/*" wildcard over the same type
+// (which also covers a wanted "type/*").
+func mimeCovers(grant, want string) bool {
+ if grant == want || grant == "*/*" {
+ return true
+ }
+ if prefix, ok := strings.CutSuffix(grant, "*"); ok && strings.HasSuffix(prefix, "/") {
+ return strings.HasPrefix(want, prefix)
+ }
+ return false
+}
diff --git a/pkg/auth/oauth/server.go b/pkg/auth/oauth/server.go
index 19e252c..2b01e90 100644
--- a/pkg/auth/oauth/server.go
+++ b/pkg/auth/oauth/server.go
@@ -1,12 +1,14 @@
package oauth
import (
+ "bytes"
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
+ "net/url"
"strings"
"time"
@@ -78,12 +80,29 @@ type UserStore interface {
// without coupling the OAuth package to AppView-specific dependencies.
type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error
+// PageRenderer renders the HTML pages the OAuth endpoints show to a person in
+// a browser, so an embedding application (the appview) can draw them in its
+// own site layout. Each method returns the complete page body; Server owns the
+// status code and headers. When a method returns an error, or no renderer is
+// set, Server falls back to the plain inline templates in this file.
+type PageRenderer interface {
+ // RenderOAuthError renders a failed authorization. message is plain text.
+ RenderOAuthError(r *http.Request, message string) ([]byte, error)
+ // RenderMissingScopes renders the refusal shown when the PDS granted
+ // fewer scopes than requested. retryURL restarts the flow.
+ RenderMissingScopes(r *http.Request, missing []string, retryURL string) ([]byte, error)
+ // RenderAuthorized renders the success page for the non-UI flow, which
+ // forwards to /settings.
+ RenderAuthorized(r *http.Request, handle string) ([]byte, error)
+}
+
// Server handles OAuth authorization for the AppView
type Server struct {
clientApp *oauth.ClientApp
refresher *Refresher
uiSessionStore UISessionStore
postAuthCallback PostAuthCallback
+ pages PageRenderer
}
// NewServer creates a new OAuth server
@@ -109,6 +128,12 @@ func (s *Server) SetPostAuthCallback(callback PostAuthCallback) {
s.postAuthCallback = callback
}
+// SetPageRenderer sets the renderer for the browser-facing OAuth pages. Pass
+// nil to use the built-in inline templates.
+func (s *Server) SetPageRenderer(pages PageRenderer) {
+ s.pages = pages
+}
+
// ServeAuthorize handles GET /auth/oauth/authorize
func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -133,7 +158,7 @@ func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) {
// Check if error is about invalid_client_metadata (usually means PDS doesn't support required scopes)
errMsg := err.Error()
if strings.Contains(errMsg, "invalid_client_metadata") {
- s.renderError(w, "OAuth authorization failed: Your PDS does not support one or more required OAuth scopes (likely the 'blob:' scope). Please update your PDS to the latest version and try again.")
+ s.renderError(w, r, "OAuth authorization failed: Your PDS does not support one or more required OAuth scopes (likely the 'blob:' scope). Please update your PDS to the latest version and try again.")
return
}
@@ -158,7 +183,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
// Check for OAuth error
if errorParam := r.URL.Query().Get("error"); errorParam != "" {
errorDesc := r.URL.Query().Get("error_description")
- s.renderError(w, fmt.Sprintf("OAuth error: %s - %s", errorParam, errorDesc))
+ s.renderError(w, r, fmt.Sprintf("OAuth error: %s - %s", errorParam, errorDesc))
return
}
@@ -185,7 +210,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
"queryParams", r.URL.Query().Encode())
}
- s.renderError(w, fmt.Sprintf("Failed to process OAuth callback: %v", err))
+ s.renderError(w, r, fmt.Sprintf("Failed to process OAuth callback: %v", err))
return
}
@@ -194,6 +219,26 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
slog.Debug("OAuth callback successful", "did", did, "sessionID", sessionID)
+ // Refuse a partial grant. Some PDSes let the user untick permissions, or
+ // leave one off the consent screen, and a session missing one works until
+ // the first push that needs it. Signing in again only helps once the PDS
+ // offers the full set, so say what's missing now. This runs before the
+ // old-session cleanup below so a refused login leaves any working session
+ // the user already had untouched.
+ if missing := MissingScopes(sessionData.Scopes, s.clientApp.Config.Scopes); len(missing) > 0 {
+ slog.Warn("Refusing OAuth login: PDS did not grant all requested scopes",
+ "component", "oauth/server",
+ "did", did,
+ "host", sessionData.HostURL,
+ "missing", missing,
+ "granted", sessionData.Scopes)
+ if err := s.clientApp.Store.DeleteSession(r.Context(), sessionData.AccountDID, sessionID); err != nil {
+ slog.Warn("Failed to delete refused OAuth session", "did", did, "error", err)
+ }
+ s.renderMissingScopes(w, r, missing)
+ return
+ }
+
// Clean up old OAuth sessions for this DID BEFORE invalidating cache
// This prevents accumulation of stale sessions with expired refresh tokens
// Order matters: delete from DB first, then invalidate cache, so when cache reloads
@@ -240,7 +285,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
slog.Error("Failed to create UI session", "error", err, "did", did)
- s.renderError(w, "Something went wrong while logging you in. Please try again.")
+ s.renderError(w, r, "Something went wrong while logging you in. Please try again.")
return
}
// Set UI session cookie and redirect (code below)
@@ -264,7 +309,7 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
slog.Error("Failed to create UI session", "error", err, "did", did)
- s.renderError(w, "Something went wrong while logging you in. Please try again.")
+ s.renderError(w, r, "Something went wrong while logging you in. Please try again.")
return
}
// Set UI session cookie
@@ -311,38 +356,85 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
}
// Non-UI flow: redirect to settings to get API key
- s.renderRedirectToSettings(w, handle)
+ s.renderRedirectToSettings(w, r, handle)
}
-// renderRedirectToSettings redirects to the settings page to generate an API key
-func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) {
- tmpl := template.Must(template.New("redirect").Parse(redirectToSettingsTemplate))
+// writePage writes an HTML page with the given status. It prefers the
+// injected renderer and falls back to the inline template when there is none
+// or it fails.
+func (s *Server) writePage(w http.ResponseWriter, status int, render func(PageRenderer) ([]byte, error), fallbackName, fallback string, data any) {
+ var body []byte
+ if s.pages != nil {
+ b, err := render(s.pages)
+ if err == nil {
+ body = b
+ } else {
+ slog.Warn("OAuth page renderer failed, using fallback template",
+ "component", "oauth/server", "page", fallbackName, "error", err)
+ }
+ }
+ if body == nil {
+ var buf bytes.Buffer
+ tmpl := template.Must(template.New(fallbackName).Parse(fallback))
+ if err := tmpl.Execute(&buf, data); err != nil {
+ http.Error(w, "failed to render template", http.StatusInternalServerError)
+ return
+ }
+ body = buf.Bytes()
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write(body)
+}
+
+// renderRedirectToSettings renders the success page for the non-UI flow,
+// which forwards to the settings page.
+func (s *Server) renderRedirectToSettings(w http.ResponseWriter, r *http.Request, handle string) {
data := struct {
Handle string
}{
Handle: handle,
}
-
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := tmpl.Execute(w, data); err != nil {
- http.Error(w, "failed to render template", http.StatusInternalServerError)
- }
+ s.writePage(w, http.StatusOK, func(p PageRenderer) ([]byte, error) {
+ return p.RenderAuthorized(r, handle)
+ }, "redirect", redirectToSettingsTemplate, data)
}
// renderError renders an error page
-func (s *Server) renderError(w http.ResponseWriter, message string) {
- tmpl := template.Must(template.New("error").Parse(errorTemplate))
+func (s *Server) renderError(w http.ResponseWriter, r *http.Request, message string) {
data := struct {
Message string
}{
Message: message,
}
+ s.writePage(w, http.StatusBadRequest, func(p PageRenderer) ([]byte, error) {
+ return p.RenderOAuthError(r, message)
+ }, "error", errorTemplate, data)
+}
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.WriteHeader(http.StatusBadRequest)
- if err := tmpl.Execute(w, data); err != nil {
- http.Error(w, "failed to render template", http.StatusInternalServerError)
+// renderMissingScopes renders the page shown when the PDS granted fewer
+// permissions than ATCR asked for.
+//
+// "Try again" goes back through the login page, not straight to authorize: the
+// login form is what sets the oauth_return_to cookie that gets a UI session
+// created, and it carries any return_to in flight (the device page, during a
+// credential-helper login) through to the retry.
+func (s *Server) renderMissingScopes(w http.ResponseWriter, r *http.Request, missing []string) {
+ retryURL := "/auth/oauth/login"
+ if c, err := r.Cookie("oauth_return_to"); err == nil && c.Value != "" {
+ retryURL += "?return_to=" + url.QueryEscape(c.Value)
}
+ data := struct {
+ Missing []string
+ RetryURL string
+ }{
+ Missing: missing,
+ RetryURL: retryURL,
+ }
+ s.writePage(w, http.StatusForbidden, func(p PageRenderer) ([]byte, error) {
+ return p.RenderMissingScopes(r, data.Missing, data.RetryURL)
+ }, "missing-scopes", missingScopesTemplate, data)
}
// HTML templates
@@ -399,3 +491,29 @@ const errorTemplate = `