diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 016b15b..ec19b3d 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -94,6 +94,7 @@ func main() {
r.Get("/auth/login", oauthHandler.HandleLogin)
r.Post("/auth/start", oauthHandler.HandleStart)
+ r.Post("/auth/signup", oauthHandler.HandleSignup)
r.Get("/auth/callback", oauthHandler.HandleCallback)
r.Post("/auth/logout", oauthHandler.HandleLogout)
r.Get("/auth/session", oauthHandler.HandleSession)
diff --git a/backend/internal/oauth/client.go b/backend/internal/oauth/client.go
index 991fa3a..cc3234a 100644
--- a/backend/internal/oauth/client.go
+++ b/backend/internal/oauth/client.go
@@ -208,6 +208,25 @@ func (c *Client) GetAuthServerMetadata(ctx context.Context, pds string) (*AuthSe
return &meta, nil
}
+func (c *Client) GetAuthServerMetadataForSignup(ctx context.Context, url string) (*AuthServerMetadata, error) {
+ url = strings.TrimSuffix(url, "/")
+
+ metaURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", url)
+ metaResp, err := http.Get(metaURL)
+ if err == nil && metaResp.StatusCode == 200 {
+ defer metaResp.Body.Close()
+ var meta AuthServerMetadata
+ if err := json.NewDecoder(metaResp.Body).Decode(&meta); err == nil && meta.Issuer != "" {
+ return &meta, nil
+ }
+ }
+ if metaResp != nil {
+ metaResp.Body.Close()
+ }
+
+ return c.GetAuthServerMetadata(ctx, url)
+}
+
func (c *Client) GeneratePKCE() (verifier, challenge string) {
b := make([]byte, 32)
rand.Read(b)
diff --git a/backend/internal/oauth/handler.go b/backend/internal/oauth/handler.go
index a64d0bc..02260f6 100644
--- a/backend/internal/oauth/handler.go
+++ b/backend/internal/oauth/handler.go
@@ -283,6 +283,86 @@ func (h *Handler) HandleStart(w http.ResponseWriter, r *http.Request) {
})
}
+func (h *Handler) HandleSignup(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ PdsURL string `json:"pds_url"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.PdsURL == "" {
+ http.Error(w, "PDS URL is required", http.StatusBadRequest)
+ return
+ }
+
+ client := h.getDynamicClient(r)
+ ctx := r.Context()
+
+ meta, err := client.GetAuthServerMetadataForSignup(ctx, req.PdsURL)
+ if err != nil {
+ log.Printf("Failed to get auth metadata for signup from %s: %v", req.PdsURL, err)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": "Failed to connect to PDS"})
+ return
+ }
+
+ dpopKey, err := client.GenerateDPoPKey()
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "Internal error"})
+ return
+ }
+
+ pkceVerifier, pkceChallenge := client.GeneratePKCE()
+ scope := "atproto offline_access blob:* include:at.margin.authFull"
+
+ parResp, state, dpopNonce, err := client.SendPAR(meta, "", scope, dpopKey, pkceChallenge)
+ if err != nil {
+ log.Printf("PAR request failed for signup: %v", err)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate signup"})
+ return
+ }
+
+ pending := &PendingAuth{
+ State: state,
+ DID: "",
+ Handle: "",
+ PDS: req.PdsURL,
+ AuthServer: meta.TokenEndpoint,
+ Issuer: meta.Issuer,
+ PKCEVerifier: pkceVerifier,
+ DPoPKey: dpopKey,
+ DPoPNonce: dpopNonce,
+ CreatedAt: time.Now(),
+ }
+
+ h.pendingMu.Lock()
+ h.pending[state] = pending
+ h.pendingMu.Unlock()
+
+ authURL, _ := url.Parse(meta.AuthorizationEndpoint)
+ q := authURL.Query()
+ q.Set("client_id", client.ClientID)
+ q.Set("request_uri", parResp.RequestURI)
+ authURL.RawQuery = q.Encode()
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{
+ "authorizationUrl": authURL.String(),
+ })
+}
+
func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) {
client := h.getDynamicClient(r)
@@ -318,8 +398,9 @@ func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) {
}
ctx := r.Context()
- meta, err := client.GetAuthServerMetadata(ctx, pending.PDS)
+ meta, err := client.GetAuthServerMetadataForSignup(ctx, pending.PDS)
if err != nil {
+ log.Printf("Failed to get auth metadata in callback for %s: %v", pending.PDS, err)
http.Error(w, fmt.Sprintf("Failed to get auth metadata: %v", err), http.StatusInternalServerError)
return
}
@@ -330,7 +411,7 @@ func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) {
return
}
- if tokenResp.Sub != pending.DID {
+ if pending.DID != "" && tokenResp.Sub != pending.DID {
log.Printf("Security: OAuth sub mismatch, expected %s, got %s", pending.DID, tokenResp.Sub)
http.Error(w, "Account identity mismatch, authorization returned different account", http.StatusBadRequest)
return
diff --git a/web/src/api/client.js b/web/src/api/client.js
index 71b822a..c43ce3d 100644
--- a/web/src/api/client.js
+++ b/web/src/api/client.js
@@ -452,6 +452,13 @@ export async function startLogin(handle, inviteCode) {
body: JSON.stringify({ handle, invite_code: inviteCode }),
});
}
+
+export async function startSignup(pdsUrl) {
+ return request(`${AUTH_BASE}/signup`, {
+ method: "POST",
+ body: JSON.stringify({ pds_url: pdsUrl }),
+ });
+}
export async function getTrendingTags(limit = 10) {
return request(`${API_BASE}/tags/trending?limit=${limit}`);
}
diff --git a/web/src/components/SignUpModal.jsx b/web/src/components/SignUpModal.jsx
index 127c0a0..0c8c90a 100644
--- a/web/src/components/SignUpModal.jsx
+++ b/web/src/components/SignUpModal.jsx
@@ -1,9 +1,19 @@
import { useState, useEffect } from "react";
import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react";
import { BlackskyIcon, NorthskyIcon, BlueskyIcon, TopphieIcon } from "./Icons";
-import { describeServer, createAccount, startLogin } from "../api/client";
+import { startSignup } from "../api/client";
+import logo from "../assets/logo.svg";
-const PROVIDERS = [
+const RECOMMENDED_PROVIDER = {
+ id: "margin",
+ name: "Margin",
+ service: "https://pds.margin.at",
+ Icon: null,
+ description: "Hosted by Margin, the easiest way to get started",
+ isMargin: true,
+};
+
+const OTHER_PROVIDERS = [
{
id: "bluesky",
name: "Bluesky",
@@ -24,7 +34,6 @@ const PROVIDERS = [
service: "https://northsky.social",
Icon: NorthskyIcon,
description: "A Canadian-based worker-owned cooperative",
- inviteUrl: "https://northskysocial.com/join",
},
{
id: "topphie",
@@ -41,28 +50,21 @@ const PROVIDERS = [
description: "An independent, self-hosted PDS instance",
},
{
- id: "selfhosted",
- name: "Self-Hosted",
+ id: "custom",
+ name: "Custom",
service: "",
custom: true,
Icon: null,
- description: "Connect to your own Personal Data Server",
+ description: "Connect to your own or another custom PDS",
},
];
export default function SignUpModal({ onClose }) {
- const [step, setStep] = useState(1);
- const [selectedProvider, setSelectedProvider] = useState(null);
+ const [showOtherProviders, setShowOtherProviders] = useState(false);
+ const [showCustomInput, setShowCustomInput] = useState(false);
const [customService, setCustomService] = useState("");
- const [formData, setFormData] = useState({
- handle: "",
- email: "",
- password: "",
- inviteCode: "",
- });
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const [serverInfo, setServerInfo] = useState(null);
useEffect(() => {
document.body.style.overflow = "hidden";
@@ -71,79 +73,47 @@ export default function SignUpModal({ onClose }) {
};
}, []);
- const handleProviderSelect = (provider) => {
- setSelectedProvider(provider);
- if (!provider.custom) {
- checkServer(provider.service);
- } else {
- setStep(1.5);
+ const handleProviderSelect = async (provider) => {
+ if (provider.custom) {
+ setShowCustomInput(true);
+ return;
}
- };
- const checkServer = async (url) => {
setLoading(true);
setError(null);
- try {
- let serviceUrl = url.trim();
- if (!serviceUrl.startsWith("http")) {
- serviceUrl = `https://${serviceUrl}`;
- }
- const info = await describeServer(serviceUrl);
- setServerInfo({
- ...info,
- service: serviceUrl,
- inviteCodeRequired: info.inviteCodeRequired ?? true,
- });
-
- if (selectedProvider?.custom) {
- setSelectedProvider({ ...selectedProvider, service: serviceUrl });
+ try {
+ const result = await startSignup(provider.service);
+ if (result.authorizationUrl) {
+ window.location.href = result.authorizationUrl;
}
-
- setStep(2);
} catch (err) {
console.error(err);
- setError("Could not connect to this PDS. Please check the URL.");
- } finally {
+ setError("Could not connect to this provider. Please try again.");
setLoading(false);
}
};
- const handleCreateAccount = async (e) => {
+ const handleCustomSubmit = async (e) => {
e.preventDefault();
- if (!serverInfo) return;
+ if (!customService.trim()) return;
setLoading(true);
setError(null);
- let domain =
- serverInfo.selectedDomain || serverInfo.availableUserDomains[0];
- if (!domain.startsWith(".")) {
- domain = "." + domain;
+ let serviceUrl = customService.trim();
+ if (!serviceUrl.startsWith("http")) {
+ serviceUrl = `https://${serviceUrl}`;
}
- const cleanHandle = formData.handle.trim().replace(/^@/, "");
- const fullHandle = cleanHandle.endsWith(domain)
- ? cleanHandle
- : `${cleanHandle}${domain}`;
-
try {
- await createAccount(serverInfo.service, {
- handle: fullHandle,
- email: formData.email,
- password: formData.password,
- inviteCode: formData.inviteCode,
- });
-
- const result = await startLogin(fullHandle);
+ const result = await startSignup(serviceUrl);
if (result.authorizationUrl) {
window.location.href = result.authorizationUrl;
- } else {
- onClose();
- alert("Account created! Please sign in.");
}
} catch (err) {
- setError(err.message || "Failed to create account");
+ console.error(err);
+ setError("Could not connect to this PDS. Please check the URL.");
setLoading(false);
}
};
@@ -155,237 +125,129 @@ export default function SignUpModal({ onClose }) {
- Where would you like to host your account? + {loading ? ( +
+ Connecting to provider...
-