From 6b6ce093d3bf3ac3d93824a459dab4dd7fba4dee Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Tue, 21 Apr 2026 22:29:23 -0500 Subject: [PATCH] new signup flow --- go.mod | 2 +- pkg/appview/handlers/base.go | 2 + pkg/appview/handlers/signup.go | 187 +++++++++++++++++ pkg/appview/handlers/signup_test.go | 123 +++++++++++ .../static/providers/eurosky-social.svg | 4 + .../static/providers/selfhosted-social.png | Bin 0 -> 12528 bytes pkg/appview/routes/routes.go | 7 + pkg/appview/src/css/main.css | 169 ++++++++++++++- pkg/appview/templates/pages/login.html | 13 +- .../templates/pages/signup-continue.html | 52 +++++ pkg/appview/templates/pages/signup.html | 69 ++++++ pkg/auth/oauth/signup.go | 198 ++++++++++++++++++ 12 files changed, 817 insertions(+), 9 deletions(-) create mode 100644 pkg/appview/handlers/signup.go create mode 100644 pkg/appview/handlers/signup_test.go create mode 100644 pkg/appview/public/static/providers/eurosky-social.svg create mode 100644 pkg/appview/public/static/providers/selfhosted-social.png create mode 100644 pkg/appview/templates/pages/signup-continue.html create mode 100644 pkg/appview/templates/pages/signup.html create mode 100644 pkg/auth/oauth/signup.go diff --git a/go.mod b/go.mod index 7c8f60b..7ead6b2 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 github.com/goki/freetype v1.0.5 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/go-querystring v1.2.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/ipfs/go-block-format v0.2.3 @@ -105,7 +106,6 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/snappy v1.0.0 // indirect - github.com/google/go-querystring v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect diff --git a/pkg/appview/handlers/base.go b/pkg/appview/handlers/base.go index 6db53d4..4f467f0 100644 --- a/pkg/appview/handlers/base.go +++ b/pkg/appview/handlers/base.go @@ -10,6 +10,7 @@ import ( "atcr.io/pkg/appview/webhooks" "atcr.io/pkg/auth/oauth" "atcr.io/pkg/billing" + indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/identity" ) @@ -33,6 +34,7 @@ type BaseUIHandler struct { Directory identity.Directory BillingManager *billing.Manager WebhookDispatcher *webhooks.Dispatcher + OAuthClientApp *indigooauth.ClientApp // Stores SessionStore *db.SessionStore diff --git a/pkg/appview/handlers/signup.go b/pkg/appview/handlers/signup.go new file mode 100644 index 0000000..05d7d3e --- /dev/null +++ b/pkg/appview/handlers/signup.go @@ -0,0 +1,187 @@ +package handlers + +import ( + "log/slog" + "net/http" + "strings" + + "atcr.io/pkg/auth/oauth" +) + +// SignupProvider is a curated Atmosphere PDS that accepts prompt=create. +// +// v1 keeps the list hardcoded. When the community has a curated directory of +// providers we can link or syndicate, this moves to a config file. +type SignupProvider struct { + // Domain is the brand hostname shown to the user on the picker and + // interstitial β€” typically the site they already know (tangled.org). + Domain string + + // AuthServerDomain is the actual PDS origin the OAuth redirect points + // at. When blank it defaults to Domain. Set this only when the brand + // the user recognizes is not the same host as the PDS (e.g. the + // tangled.org site runs against a tngl.sh PDS). + AuthServerDomain string + + // AvatarPath is the provider's mark. For mono vector marks, AvatarMono + // should be true and this should point to a monochrome SVG used as a + // CSS mask (the fill value in the file is irrelevant β€” theme color is + // applied via `background-color` on the element). For raster avatars + // (photographs, brand-colored icons that should render unchanged), + // AvatarMono is false and this is emitted as a plain ``. + AvatarPath string + + // AvatarMono toggles the mask-based theme-aware render pipeline. True + // for single-color SVG marks that should invert with theme; false for + // raster or multi-color art that should render as-is. + AvatarMono bool + + // RegionFlag is the flag emoji shown before the region code. + RegionFlag string + + // Region is a short code shown in the region chip ("US", "EU", ...). + Region string + + // RegionFullName is the long form shown in the chip's title attribute + // for hover/assistive tech ("United States", "European Union"). + RegionFullName string + + // TermsURL and PrivacyURL link out to the provider's policies (new tab). + // Blank links are omitted rather than rendered as dead links. + TermsURL string + PrivacyURL string +} + +// AuthServerHost returns the origin string (https://domain) passed to the +// OAuth resolver. Prefers AuthServerDomain when set (so the brand label on +// the row can differ from the PDS β€” e.g. tangled.org displayed, tngl.sh +// used for auth). Hardcoded scheme is intentional β€” PAR against http PDSes +// violates the AT Protocol OAuth spec. +func (p SignupProvider) AuthServerHost() string { + host := p.AuthServerDomain + if host == "" { + host = p.Domain + } + return "https://" + host +} + +// signupProviders is the v1 curated list. Order is the order users see. +// Avatars live at pkg/appview/public/static/providers/*.svg. +var signupProviders = []SignupProvider{ + { + Domain: "selfhosted.social", + AvatarPath: "/static/providers/selfhosted-social.png", + AvatarMono: false, // raster avatar, renders as-is + RegionFlag: "\U0001F1FA\U0001F1F8", // πŸ‡ΊπŸ‡Έ + Region: "US", + RegionFullName: "United States", + TermsURL: "https://selfhosted.social/legal", + PrivacyURL: "https://selfhosted.social/legal", + }, + { + Domain: "eurosky.social", + AvatarPath: "/static/providers/eurosky-social.svg", + AvatarMono: true, + RegionFlag: "\U0001F1EA\U0001F1FA", // πŸ‡ͺπŸ‡Ί + Region: "EU", + RegionFullName: "European Union", + TermsURL: "https://eurosky.tech/accounts/terms/", + PrivacyURL: "https://eurosky.tech/accounts/privacy/", + }, +} + +// findProvider returns the entry matching the given domain, or nil. +func findProvider(domain string) *SignupProvider { + domain = strings.ToLower(strings.TrimSpace(domain)) + for i := range signupProviders { + if signupProviders[i].Domain == domain { + return &signupProviders[i] + } + } + return nil +} + +// SignupHandler serves GET /signup β€” the provider picker. +type SignupHandler struct { + BaseUIHandler +} + +func (h *SignupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + meta := NewPageMeta( + "Create your account - "+h.ClientShortName, + "Pick an Atmosphere provider to create an account that works on "+h.ClientShortName+" and every other app on the AT Protocol.", + ).WithCanonical("https://" + h.SiteURL + "/signup"). + WithSiteName(h.ClientShortName) + + data := struct { + PageData + Meta *PageMeta + Providers []SignupProvider + }{ + PageData: NewPageData(r, &h.BaseUIHandler), + Meta: meta, + Providers: signupProviders, + } + + if err := h.Templates.ExecuteTemplate(w, "signup", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// SignupContinueHandler serves /signup/continue β€” the branded handoff. +// +// GET renders the interstitial. POST kicks off OAuth with prompt=create and +// redirects to the provider's authorize URL. +type SignupContinueHandler struct { + BaseUIHandler +} + +func (h *SignupContinueHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + to := r.URL.Query().Get("to") + if r.Method == http.MethodPost { + to = r.FormValue("to") + } + + provider := findProvider(to) + if provider == nil { + http.Redirect(w, r, "/signup", http.StatusSeeOther) + return + } + + if r.Method == http.MethodPost { + redirectURL, err := oauth.StartSignupFlow(r.Context(), h.OAuthClientApp, provider.AuthServerHost()) + if err != nil { + slog.Error("signup flow start failed", + "component", "signup", + "provider", provider.Domain, + "error", err, + ) + http.Redirect(w, r, "/signup?error=provider_unreachable&domain="+provider.Domain, http.StatusSeeOther) + return + } + http.Redirect(w, r, redirectURL, http.StatusFound) + return + } + + meta := NewPageMeta( + "Create your account on "+provider.Domain+" - "+h.ClientShortName, + "You're about to be sent to "+provider.Domain+" to create your account.", + ).WithRobots("noindex"). + WithSiteName(h.ClientShortName) + + data := struct { + PageData + Meta *PageMeta + Provider SignupProvider + }{ + PageData: NewPageData(r, &h.BaseUIHandler), + Meta: meta, + Provider: *provider, + } + + if err := h.Templates.ExecuteTemplate(w, "signup-continue", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/pkg/appview/handlers/signup_test.go b/pkg/appview/handlers/signup_test.go new file mode 100644 index 0000000..13e7c67 --- /dev/null +++ b/pkg/appview/handlers/signup_test.go @@ -0,0 +1,123 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "atcr.io/pkg/appview" + "atcr.io/pkg/appview/handlers" +) + +func TestSignupHandler_RendersProviderPicker(t *testing.T) { + templates, err := appview.Templates(nil) + if err != nil { + t.Fatalf("load templates: %v", err) + } + + h := &handlers.SignupHandler{ + BaseUIHandler: handlers.BaseUIHandler{ + Templates: templates, + RegistryURL: "seamark.dev", + SiteURL: "seamark.dev", + ClientShortName: "Seamark", + }, + } + + req := httptest.NewRequest("GET", "/signup", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + + body := rr.Body.String() + + // Each provider's domain should appear as a row title. + for _, domain := range []string{"selfhosted.social", "eurosky.social"} { + if !strings.Contains(body, domain) { + t.Errorf("missing provider domain %q in body", domain) + } + } + + // The {appviewName} substitution should carry through from ClientShortName. + if !strings.Contains(body, "Seamark") { + t.Error("expected rendered body to include ClientShortName 'Seamark'") + } + + // Each row should link to the branded handoff, not directly to OAuth. + if !strings.Contains(body, "/signup/continue?to=selfhosted.social") { + t.Error("expected CTA link to /signup/continue?to={domain}") + } + + // Fallback route to existing Bluesky users must remain intact. + if !strings.Contains(body, `href="/auth/oauth/login"`) { + t.Error("expected footer link to /auth/oauth/login") + } +} + +func TestSignupContinueHandler_GETRendersInterstitial(t *testing.T) { + templates, err := appview.Templates(nil) + if err != nil { + t.Fatalf("load templates: %v", err) + } + + h := &handlers.SignupContinueHandler{ + BaseUIHandler: handlers.BaseUIHandler{ + Templates: templates, + RegistryURL: "seamark.dev", + SiteURL: "seamark.dev", + ClientShortName: "Seamark", + }, + } + + req := httptest.NewRequest("GET", "/signup/continue?to=eurosky.social", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + + body := rr.Body.String() + + if !strings.Contains(body, "eurosky.social") { + t.Error("expected interstitial to show target domain") + } + if !strings.Contains(body, `action="/signup/continue"`) { + t.Error("expected continue form to POST back to /signup/continue") + } + if !strings.Contains(body, `href="/signup"`) { + t.Error("expected back-link to /signup") + } + // Atmosphere story anchored with concrete app names. + if !strings.Contains(body, "Bluesky") || !strings.Contains(body, "Tangled") { + t.Error("expected body copy to name Bluesky and Tangled") + } +} + +func TestSignupContinueHandler_UnknownProviderRedirectsToPicker(t *testing.T) { + templates, err := appview.Templates(nil) + if err != nil { + t.Fatalf("load templates: %v", err) + } + + h := &handlers.SignupContinueHandler{ + BaseUIHandler: handlers.BaseUIHandler{ + Templates: templates, + }, + } + + req := httptest.NewRequest("GET", "/signup/continue?to=not-a-real-provider.example", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusSeeOther) + } + if loc := rr.Header().Get("Location"); loc != "/signup" { + t.Errorf("redirect Location = %q, want /signup", loc) + } +} diff --git a/pkg/appview/public/static/providers/eurosky-social.svg b/pkg/appview/public/static/providers/eurosky-social.svg new file mode 100644 index 0000000..f45b1be --- /dev/null +++ b/pkg/appview/public/static/providers/eurosky-social.svg @@ -0,0 +1,4 @@ + + + + diff --git a/pkg/appview/public/static/providers/selfhosted-social.png b/pkg/appview/public/static/providers/selfhosted-social.png new file mode 100644 index 0000000000000000000000000000000000000000..474303006352b068e7e499f0814da82f4961c34a GIT binary patch literal 12528 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 sure if you have an account?

- An Atmosphere Account is a portable identity on the AT Protocol β€” - the same network that powers Bluesky, Tangled, and other apps. One account works - across every application built on the protocol. + An Atmosphere account is your personal identity across a whole network + of apps. Think of it as a digital passport that you own, not locked to any single app + or company.

- The easiest way to create one is at - bsky.app. - Already have a Bluesky handle? You're all set β€” use it here. + It works the way an email address does: the same account signs you into many different + apps. If you already have an account from a site like Bluesky, Blacksky, or Tangled, + you're already set. If you don't, you can + create one here β†’

diff --git a/pkg/appview/templates/pages/signup-continue.html b/pkg/appview/templates/pages/signup-continue.html new file mode 100644 index 0000000..2aaf2ca --- /dev/null +++ b/pkg/appview/templates/pages/signup-continue.html @@ -0,0 +1,52 @@ +{{ define "signup-continue" }} + + + + {{ template "head" . }} + {{ template "meta" .Meta }} + + + {{ template "nav-simple" . }} + +
+
+ + +

+ Create your account on
+ {{ .Provider.Domain }} +

+ +

+ You're being sent to + {{ .Provider.Domain }} + to create your account. + The same account works on {{ .ClientShortName }} and every other app on the Atmosphere, + including Bluesky, Tangled, and dozens more. +

+ +
+ + + + {{ icon "chevron-left" "size-4" }} + Pick a different provider + +
+
+
+ + {{ template "footer" . }} + + +{{ end }} diff --git a/pkg/appview/templates/pages/signup.html b/pkg/appview/templates/pages/signup.html new file mode 100644 index 0000000..dd6bea1 --- /dev/null +++ b/pkg/appview/templates/pages/signup.html @@ -0,0 +1,69 @@ +{{ define "signup" }} + + + + {{ template "head" . }} + {{ template "meta" .Meta }} + + + {{ template "nav-simple" . }} + +
+
+
+

+ Create your Atmosphere account +

+

+ Pick a provider. Your account works on {{ .ClientShortName }} and every other app on the Atmosphere. +

+
+ +
    + {{ range $i, $p := .Providers }} +
  • +
    +
    + {{ if $p.AvatarMono }} + + {{ else }} + + {{ end }} +
    +
    +
    + {{ $p.Domain }} + + {{ if $p.RegionFlag }}{{ end }} + {{ $p.Region }} + +
    +
    + {{ if $p.TermsURL }}Terms{{ end }} + {{ if and $p.TermsURL $p.PrivacyURL }}{{ end }} + {{ if $p.PrivacyURL }}Privacy{{ end }} +
    +
    + + Create account + {{ icon "arrow-right" "size-4" }} + +
    +
  • + {{ end }} +
+ +

+ Already have an Atmosphere account? + Sign in β†’ +

+
+
+ + {{ template "footer" . }} + + +{{ end }} diff --git a/pkg/auth/oauth/signup.go b/pkg/auth/oauth/signup.go new file mode 100644 index 0000000..c0f3ffe --- /dev/null +++ b/pkg/auth/oauth/signup.go @@ -0,0 +1,198 @@ +package oauth + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/google/go-querystring/query" +) + +// StartSignupFlow starts an OAuth authorization flow pointed at a specific +// authorization server with prompt=create, asking the server to show its +// signup UI rather than its login UI. +// +// indigo's ClientApp.StartAuthFlow does not expose prompt, so this +// duplicates the PAR + redirect build using only exported indigo helpers. +// The resulting AuthRequestData is persisted via clientApp.Store so the +// existing ServeCallback path handles the return leg unchanged. +// +// authServerHost is the PDS origin (e.g. "https://eurosky.social"). It gets +// resolved to the actual OAuth auth server URL before the PAR call. +func StartSignupFlow(ctx context.Context, clientApp *oauth.ClientApp, authServerHost string) (string, error) { + authserverURL, err := clientApp.Resolver.ResolveAuthServerURL(ctx, authServerHost) + if err != nil { + return "", fmt.Errorf("resolving auth server for %s: %w", authServerHost, err) + } + + authserverMeta, err := clientApp.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) + if err != nil { + return "", fmt.Errorf("fetching auth server metadata: %w", err) + } + + state, err := secureRandomBase64(16) + if err != nil { + return "", fmt.Errorf("generating state: %w", err) + } + pkceVerifier, err := secureRandomBase64(48) + if err != nil { + return "", fmt.Errorf("generating PKCE verifier: %w", err) + } + codeChallenge := oauth.S256CodeChallenge(pkceVerifier) + prompt := "create" + + body := oauth.PushedAuthRequest{ + ClientID: clientApp.Config.ClientID, + State: state, + RedirectURI: clientApp.Config.CallbackURL, + Scope: scopeString(clientApp.Config.Scopes), + ResponseType: "code", + CodeChallenge: codeChallenge, + CodeChallengeMethod: "S256", + Prompt: &prompt, + } + + if clientApp.Config.IsConfidential() { + assertionJWT, err := clientApp.Config.NewClientAssertion(authserverMeta.Issuer) + if err != nil { + return "", fmt.Errorf("client assertion: %w", err) + } + body.ClientAssertionType = oauth.ClientAssertionJWTBearer + body.ClientAssertion = assertionJWT + } + + vals, err := query.Values(body) + if err != nil { + return "", fmt.Errorf("encoding PAR body: %w", err) + } + bodyBytes := []byte(vals.Encode()) + + dpopPrivKey, err := atcrypto.GeneratePrivateKeyP256() + if err != nil { + return "", fmt.Errorf("generating DPoP key: %w", err) + } + + parURL := authserverMeta.PushedAuthorizationRequestEndpoint + dpopServerNonce := "" + var resp *http.Response + for range 2 { + dpopJWT, err := oauth.NewAuthDPoP("POST", parURL, dpopServerNonce, dpopPrivKey) + if err != nil { + return "", fmt.Errorf("DPoP JWT: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", parURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + return "", fmt.Errorf("new PAR request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("DPoP", dpopJWT) + + resp, err = clientApp.Client.Do(req) + if err != nil { + return "", fmt.Errorf("PAR request: %w", err) + } + + if n := resp.Header.Get("DPoP-Nonce"); n != "" { + dpopServerNonce = n + } + + // Retry once on DPoP nonce challenge + if resp.StatusCode == http.StatusBadRequest && dpopServerNonce != "" { + reason := readAuthError(resp) + if reason == "use_dpop_nonce" { + continue + } + return "", fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, reason) + } + break + } + + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, readAuthError(resp)) + } + + var parResp oauth.PushedAuthResponse + if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil { + return "", fmt.Errorf("decoding PAR response: %w", err) + } + + info := oauth.AuthRequestData{ + State: state, + AuthServerURL: authserverMeta.Issuer, + Scopes: clientApp.Config.Scopes, + PKCEVerifier: pkceVerifier, + RequestURI: parResp.RequestURI, + AuthServerTokenEndpoint: authserverMeta.TokenEndpoint, + AuthServerRevocationEndpoint: authserverMeta.RevocationEndpoint, + DPoPAuthServerNonce: dpopServerNonce, + DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(), + } + + if err := clientApp.Store.SaveAuthRequestInfo(ctx, info); err != nil { + return "", fmt.Errorf("saving auth request info: %w", err) + } + + params := url.Values{} + params.Set("client_id", clientApp.Config.ClientID) + params.Set("request_uri", parResp.RequestURI) + redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode()) + + slog.Debug("started signup flow", + "authserver", authserverMeta.Issuer, + "state", state, + "redirect", redirectURL, + ) + + return redirectURL, nil +} + +// secureRandomBase64 returns `sizeBytes` random bytes base64 (URL-safe, no padding) encoded. +// Mirrors indigo's private helper of the same name. +func secureRandomBase64(sizeBytes int) (string, error) { + buf := make([]byte, sizeBytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// scopeString joins OAuth scopes with spaces (OAuth 2.0 / RFC 6749 Β§3.3). +func scopeString(scopes []string) string { + out := "" + for i, s := range scopes { + if i > 0 { + out += " " + } + out += s + } + return out +} + +// readAuthError best-effort extracts the `error` code from an OAuth error +// response body and always closes the body. Mirrors indigo's private +// parseAuthErrorReason. +func readAuthError(resp *http.Response) string { + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + var e struct { + Error string `json:"error"` + } + if json.Unmarshal(b, &e) == nil && e.Error != "" { + return e.Error + } + return string(b) +}