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 0000000..4743030 Binary files /dev/null and b/pkg/appview/public/static/providers/selfhosted-social.png differ diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index 41bafbb..35fdac5 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -70,6 +70,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { HealthChecker: deps.HealthChecker, ReadmeFetcher: deps.ReadmeFetcher, Directory: deps.OAuthClientApp.Dir, + OAuthClientApp: deps.OAuthClientApp, SessionStore: deps.SessionStore, DeviceStore: deps.DeviceStore, OAuthStore: deps.OAuthStore, @@ -88,6 +89,12 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { router.Get("/auth/oauth/login", (&uihandlers.LoginHandler{BaseUIHandler: base}).ServeHTTP) router.Post("/auth/oauth/login", (&uihandlers.LoginSubmitHandler{BaseUIHandler: base}).ServeHTTP) + // Signup: provider picker + branded OAuth handoff (both public) + router.Get("/signup", (&uihandlers.SignupHandler{BaseUIHandler: base}).ServeHTTP) + signupContinue := &uihandlers.SignupContinueHandler{BaseUIHandler: base} + router.Get("/signup/continue", signupContinue.ServeHTTP) + router.Post("/signup/continue", signupContinue.ServeHTTP) + // Public routes (with optional auth for navbar) router.Get("/", middleware.OptionalAuth(deps.SessionStore, deps.Database)( &uihandlers.HomeHandler{BaseUIHandler: base}, diff --git a/pkg/appview/src/css/main.css b/pkg/appview/src/css/main.css index d683a6a..f29f5cf 100644 --- a/pkg/appview/src/css/main.css +++ b/pkg/appview/src/css/main.css @@ -730,7 +730,7 @@ SAILOR INFO DISCLOSURE ---------------------------------------- */ .sailor-info summary { - @apply cursor-pointer text-sm text-base-content/70; + @apply cursor-pointer text-base font-medium text-base-content/80; @apply py-2 select-none; list-style: none; } @@ -749,7 +749,7 @@ } .sailor-info-body { - @apply text-sm text-base-content/70 space-y-2 pl-5 pt-1 pb-2; + @apply text-base text-base-content/75 leading-relaxed space-y-3 pl-5 pt-1 pb-2; } /* ---------------------------------------- @@ -834,6 +834,171 @@ .vuln-box-high { background-color: var(--color-severity-high); color: var(--color-severity-high-content); } .vuln-box-medium { background-color: var(--color-severity-medium); color: var(--color-severity-medium-content); } .vuln-box-low { background-color: var(--color-severity-low); color: var(--color-severity-low-content); } + + /* ---------------------------------------- + SIGNUP PROVIDER LIST + A single bordered container where each row is a "mooring" the user + can pick between. No per-row card, no nested borders, no shadows. + + The single maritime gesture on this surface is the row divider: + a repeating short-dash pattern that reads as a depth-sounding plot + mark. It replaces a plain 1px rule without becoming decoration + (the dividers still do the work of separating rows). + + Typography intent: the domain is rendered in the mono face because + the domain IS the identity β€” same thing users will see in the URL + bar on the provider's signup page. Mono treats it as an instrument + label, not prose. + ---------------------------------------- */ + .provider-row + .provider-row { + /* Background-image is a row of 4px dashes with 4px gaps β€” + the plotted-depth look without resorting to a border-image. */ + background-image: linear-gradient( + to right, + var(--color-base-300) 0 4px, + transparent 4px 8px + ); + background-repeat: repeat-x; + background-size: 8px 1px; + background-position: left top; + } + + .provider-row-inner { + @apply flex items-center gap-4 p-4 sm:px-5; + @apply transition-colors duration-150; + background: transparent; + } + + .provider-row:hover .provider-row-inner { + background: color-mix(in oklch, var(--color-base-200) 55%, transparent); + } + + .provider-mark { + @apply shrink-0 w-10 h-10 rounded-full overflow-hidden bg-base-200; + @apply flex items-center justify-center; + /* Subtle inner ring so the avatar has a defined edge even when the + art fills corner-to-corner. */ + box-shadow: inset 0 0 0 1px oklch(from var(--color-base-content) l c h / 0.08); + } + .provider-mark img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + /* Mono mark variant: the SVG is used as a CSS mask and the element's + background-color carries the theme-aware fill. One file per provider, + theme colors driven by CSS, no shipping of light/dark pairs. */ + .provider-mark-mono { + display: block; + width: 60%; + height: 60%; + background-color: var(--color-base-content); + mask-image: var(--mark-url); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-image: var(--mark-url); + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + } + + .provider-body { + @apply flex-1 min-w-0 flex flex-col; + gap: 0.125rem; + } + + .provider-title-row { + @apply flex items-baseline gap-2 flex-wrap; + } + + .provider-domain { + font-family: var(--font-mono); + font-weight: 500; + font-size: 0.975rem; + letter-spacing: -0.01em; + color: var(--color-base-content); + } + + /* Region chip. Tight, instrumenty. Tabular-like letterforms via the + mono face; flag emoji sits first and uses the system emoji font stack + to keep rendering consistent across platforms (Windows, Linux, macOS). */ + .provider-chip { + @apply inline-flex items-center gap-1.5 px-2 py-[0.15rem] rounded-sm; + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.04em; + color: color-mix(in oklch, var(--color-base-content) 70%, transparent); + background: color-mix(in oklch, var(--color-base-300) 55%, transparent); + } + .provider-chip-flag { + font-family: + "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", + "Twemoji Mozilla", "EmojiOne Color", sans-serif; + font-size: 0.9em; + line-height: 1; + letter-spacing: 0; + } + + .provider-meta { + @apply flex items-center gap-2 text-xs; + color: color-mix(in oklch, var(--color-base-content) 55%, transparent); + } + .provider-meta a { + @apply underline-offset-2; + } + .provider-meta a:hover { + @apply underline text-base-content; + } + .provider-meta span { + color: color-mix(in oklch, var(--color-base-content) 30%, transparent); + } + + .provider-cta { + @apply shrink-0; + } + + /* Mobile: avatar + domain on row 1; region/links + CTA on row 2 so the + CTA gets full width and the row doesn't wrap into 3 lines. */ + @media (max-width: 32rem) { + .provider-row-inner { + display: grid; + grid-template-columns: 2.5rem 1fr; + grid-template-areas: + "mark body" + "cta cta"; + row-gap: 0.85rem; + } + .provider-mark { grid-area: mark; } + .provider-body { grid-area: body; } + .provider-cta { grid-area: cta; width: 100%; justify-content: center; } + } + + /* ---------------------------------------- + SIGNUP CONTINUE β€” handoff mark + A slightly larger avatar ringed in primary, so the "you are leaving" + page reads as explicitly about the destination provider. + ---------------------------------------- */ + .signup-handoff-mark { + @apply w-16 h-16 rounded-full overflow-hidden bg-base-200; + @apply flex items-center justify-center; + box-shadow: + 0 0 0 1px var(--color-base-300), + 0 0 0 5px color-mix(in oklch, var(--color-primary) 12%, transparent); + } + .signup-handoff-mark img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + .signup-handoff-mark .provider-mark-mono { + width: 60%; + height: 60%; + } } /* ======================================== diff --git a/pkg/appview/templates/pages/login.html b/pkg/appview/templates/pages/login.html index a45ac40..9462243 100644 --- a/pkg/appview/templates/pages/login.html +++ b/pkg/appview/templates/pages/login.html @@ -67,14 +67,15 @@ Not 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. +

+
+ + + +

+ 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) +}