mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
new signup flow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<img>`.
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1022 1022" width="40" height="40" role="img" aria-label="eurosky.social">
|
||||
<path fill="currentColor" d="M990.9671,522.1991c-3.0947,0-6.1689,0-9.2229.0001-456.4914.0202-459.525,3.0539-459.5452,459.5452-.0001,3.0539-.0001,6.1282-.0001,9.2228h-44.3979c0-3.0947,0-6.1689-.0001-9.2229-.0202-456.4913-3.0538-459.525-459.5452-459.5452-3.0539-.0001-6.1282-.0001-9.2229-.0001v-44.3981c3.0947,0,6.1689,0,9.2229-.0001,456.4914-.0202,459.525-3.0534,459.5452-459.5452.0001-3.0539.0001-6.1282.0001-9.2229h44.3979c0,3.0947,0,6.1689.0001,9.2228.0202,456.4917,3.0538,459.525,459.5452,459.5452,3.0539.0001,6.1282.0001,9.2229.0001v44.3981Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 724 B |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -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},
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
|
||||
@@ -67,14 +67,15 @@
|
||||
<summary>Not sure if you have an account?</summary>
|
||||
<div class="sailor-info-body">
|
||||
<p>
|
||||
An <strong>Atmosphere Account</strong> 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 <strong>Atmosphere account</strong> 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.
|
||||
</p>
|
||||
<p>
|
||||
The easiest way to create one is at
|
||||
<a href="https://bsky.app" target="_blank" rel="noopener noreferrer" class="link link-primary">bsky.app</a>.
|
||||
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
|
||||
<a href="/signup" class="link link-primary">create one here →</a>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{{ define "signup-continue" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav-simple" . }}
|
||||
|
||||
<main id="main-content" class="flex-1 flex items-start justify-center px-4 pt-16 sm:pt-24 pb-20">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="signup-handoff-mark mb-8">
|
||||
{{ if .Provider.AvatarMono }}
|
||||
<span class="provider-mark-mono" style="--mark-url: url('{{ .Provider.AvatarPath }}')" role="img" aria-label="{{ .Provider.Domain }}"></span>
|
||||
{{ else }}
|
||||
<img src="{{ .Provider.AvatarPath }}" alt="" width="64" height="64" loading="lazy" decoding="async">
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl sm:text-3xl font-display font-semibold tracking-tight leading-[1.15]">
|
||||
Create your account on<br>
|
||||
<span class="font-mono font-semibold text-primary tracking-tight">{{ .Provider.Domain }}</span>
|
||||
</h1>
|
||||
|
||||
<p class="mt-5 text-base-content/75 leading-relaxed">
|
||||
You're being sent to
|
||||
<span class="font-mono">{{ .Provider.Domain }}</span>
|
||||
to create your account.
|
||||
The same account works on {{ .ClientShortName }} and every other app on the Atmosphere,
|
||||
including Bluesky, Tangled, and dozens more.
|
||||
</p>
|
||||
|
||||
<form action="/signup/continue" method="POST" class="mt-8 flex flex-col gap-3"
|
||||
onsubmit="var b=this.querySelector('button[type=submit]');if(b){b.disabled=true;b.querySelector('.cta-label').textContent='Redirecting…';}">
|
||||
<input type="hidden" name="to" value="{{ .Provider.Domain }}">
|
||||
<button type="submit" autofocus class="btn btn-primary btn-lg">
|
||||
<span class="cta-label">Continue to {{ .Provider.Domain }}</span>
|
||||
{{ icon "arrow-right" "size-4" }}
|
||||
</button>
|
||||
<a href="/signup" class="btn btn-ghost btn-sm self-start mt-1 -ml-3">
|
||||
{{ icon "chevron-left" "size-4" }}
|
||||
Pick a different provider
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,69 @@
|
||||
{{ define "signup" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav-simple" . }}
|
||||
|
||||
<main id="main-content" class="flex-1 flex items-start justify-center px-4 pt-16 sm:pt-24 pb-20">
|
||||
<div class="w-full max-w-2xl">
|
||||
<header class="mb-10">
|
||||
<h1 class="text-3xl sm:text-4xl font-display font-semibold tracking-tight leading-[1.1]">
|
||||
Create your Atmosphere account
|
||||
</h1>
|
||||
<p class="mt-4 text-base-content/70 text-base leading-relaxed max-w-md">
|
||||
Pick a provider. Your account works on {{ .ClientShortName }} and every other app on the Atmosphere.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="provider-list border border-base-300 rounded-md bg-base-100 overflow-hidden"
|
||||
aria-label="Atmosphere providers">
|
||||
{{ range $i, $p := .Providers }}
|
||||
<li class="provider-row group">
|
||||
<div class="provider-row-inner">
|
||||
<div class="provider-mark">
|
||||
{{ if $p.AvatarMono }}
|
||||
<span class="provider-mark-mono" style="--mark-url: url('{{ $p.AvatarPath }}')" role="img" aria-label="{{ $p.Domain }}"></span>
|
||||
{{ else }}
|
||||
<img src="{{ $p.AvatarPath }}" alt="" width="40" height="40" loading="lazy" decoding="async">
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="provider-body">
|
||||
<div class="provider-title-row">
|
||||
<span class="provider-domain">{{ $p.Domain }}</span>
|
||||
<span class="provider-chip" title="{{ $p.RegionFullName }}">
|
||||
{{ if $p.RegionFlag }}<span class="provider-chip-flag" aria-hidden="true">{{ $p.RegionFlag }}</span>{{ end }}
|
||||
{{ $p.Region }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="provider-meta">
|
||||
{{ if $p.TermsURL }}<a href="{{ $p.TermsURL }}" target="_blank" rel="noopener noreferrer">Terms</a>{{ end }}
|
||||
{{ if and $p.TermsURL $p.PrivacyURL }}<span aria-hidden="true">·</span>{{ end }}
|
||||
{{ if $p.PrivacyURL }}<a href="{{ $p.PrivacyURL }}" target="_blank" rel="noopener noreferrer">Privacy</a>{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<a href="/signup/continue?to={{ $p.Domain }}"
|
||||
class="btn btn-primary btn-sm provider-cta"
|
||||
{{ if eq $i 0 }}autofocus{{ end }}>
|
||||
Create account
|
||||
{{ icon "arrow-right" "size-4" }}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
|
||||
<p class="mt-8 text-sm text-base-content/60">
|
||||
Already have an Atmosphere account?
|
||||
<a href="/auth/oauth/login" class="link link-primary">Sign in →</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user