diff --git a/bskyweb/cmd/bskyweb/invitepass.go b/bskyweb/cmd/bskyweb/invitepass.go index 601cd9b5ff..51dd7943dd 100644 --- a/bskyweb/cmd/bskyweb/invitepass.go +++ b/bskyweb/cmd/bskyweb/invitepass.go @@ -13,6 +13,7 @@ import ( "io" "io/fs" "net/http" + "net/url" "strings" "time" @@ -29,21 +30,22 @@ const inviteHeroPath = "/invite/wallet/hero" var errAuthMissing = errors.New("auth missing") // Authenticator is satisfied by the production session checker and by stub -// auth in tests. +// auth in tests. pdsHost is the user's home PDS hostname extracted from the +// DID document service endpoint. type Authenticator interface { - Authenticate(c echo.Context) (did, handle string, err error) + Authenticate(c echo.Context) (did, handle, pdsHost string, err error) } // XrpcAuthenticator calls com.atproto.server.getSession with the incoming bearer -// token, verifying the session and returning the (did, handle). +// token, verifying the session and returning (did, handle, pdsHost). type XrpcAuthenticator struct { - BaseHost string // e.g. "https://public.api.bsky.app" + BaseHost string // e.g. "https://bsky.social" } -func (a XrpcAuthenticator) Authenticate(c echo.Context) (string, string, error) { +func (a XrpcAuthenticator) Authenticate(c echo.Context) (string, string, string, error) { hdr := c.Request().Header.Get("Authorization") if hdr == "" || !strings.HasPrefix(hdr, "Bearer ") { - return "", "", errAuthMissing + return "", "", "", errAuthMissing } jwt := strings.TrimPrefix(hdr, "Bearer ") client := &xrpc.Client{ @@ -52,9 +54,42 @@ func (a XrpcAuthenticator) Authenticate(c echo.Context) (string, string, error) } resp, err := comatproto.ServerGetSession(c.Request().Context(), client) if err != nil { - return "", "", errAuthMissing + return "", "", "", errAuthMissing } - return resp.Did, resp.Handle, nil + return resp.Did, resp.Handle, extractPDSHost(resp.DidDoc), nil +} + +// extractPDSHost parses the AtprotoPersonalDataServer service entry from the +// DID document returned by getSession and returns just the hostname (no scheme +// or path). Returns "" if absent or unparseable - the caller can fall back to +// a default. +func extractPDSHost(didDoc any) string { + if didDoc == nil { + return "" + } + raw, err := json.Marshal(didDoc) + if err != nil { + return "" + } + var doc struct { + Service []struct { + Type string `json:"type"` + ServiceEndpoint string `json:"serviceEndpoint"` + } `json:"service"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return "" + } + for _, s := range doc.Service { + if s.Type != "AtprotoPersonalDataServer" { + continue + } + u, err := url.Parse(s.ServiceEndpoint) + if err == nil && u.Hostname() != "" { + return u.Hostname() + } + } + return "" } type InvitePassConfig struct { @@ -78,7 +113,7 @@ func (srv *Server) WebInvitePassURL(c echo.Context) error { if len(srv.cfg.InvitePass.TokenSecret) == 0 { return c.JSON(http.StatusServiceUnavailable, echo.Map{"error": "InvitePassDisabled"}) } - did, _, err := srv.authenticator.Authenticate(c) + did, _, pdsHost, err := srv.authenticator.Authenticate(c) if err != nil { return c.JSON(http.StatusUnauthorized, echo.Map{"error": "AuthMissing"}) } @@ -87,7 +122,7 @@ func (srv *Server) WebInvitePassURL(c echo.Context) error { } _ = json.NewDecoder(c.Request().Body).Decode(&body) theme := CoerceTheme(body.Theme) - tok, err := MintPassToken(srv.cfg.InvitePass.TokenSecret, did, theme, time.Now(), passTokenTTL) + tok, err := MintPassToken(srv.cfg.InvitePass.TokenSecret, did, theme, pdsHost, time.Now(), passTokenTTL) if err != nil { return c.JSON(http.StatusInternalServerError, echo.Map{"error": "TokenMintFailed"}) } @@ -101,7 +136,7 @@ func (srv *Server) WebInvitePassPkpass(c echo.Context) error { } theme := CoerceTheme(c.QueryParam("theme")) tok := c.QueryParam("t") - did, tokTheme, err := VerifyPassToken(srv.cfg.InvitePass.TokenSecret, tok, time.Now()) + did, tokTheme, pdsHost, err := VerifyPassToken(srv.cfg.InvitePass.TokenSecret, tok, time.Now()) if err != nil { return c.JSON(http.StatusGone, echo.Map{"error": "TokenInvalid"}) } @@ -112,12 +147,12 @@ func (srv *Server) WebInvitePassPkpass(c echo.Context) error { return c.JSON(http.StatusServiceUnavailable, echo.Map{"error": "SignerDisabled"}) } - handle, displayName, avatarBytes, err := srv.fetchProfile(c.Request().Context(), did) + handle, displayName, createdAt, avatarBytes, err := srv.fetchProfile(c.Request().Context(), did) if err != nil { return c.JSON(http.StatusBadGateway, echo.Map{"error": "ProfileFetchFailed"}) } - passJSON, err := BuildPassJSON(did, handle, displayName, theme, srv.cfg.InvitePass.TeamID) + passJSON, err := BuildPassJSON(did, handle, displayName, pdsHost, theme, srv.cfg.InvitePass.TeamID, createdAt) if err != nil { return c.JSON(http.StatusInternalServerError, echo.Map{"error": "PassBuildFailed"}) } @@ -140,7 +175,7 @@ func (srv *Server) WebInvitePassPkpass(c echo.Context) error { } func (srv *Server) WebInviteWalletJWT(c echo.Context) error { - did, handle, err := srv.authenticator.Authenticate(c) + did, handle, _, err := srv.authenticator.Authenticate(c) if err != nil { return c.JSON(http.StatusUnauthorized, echo.Map{"error": "AuthMissing"}) } @@ -165,7 +200,7 @@ func (srv *Server) WebInviteWalletHero(c echo.Context) error { if did == "" { return c.NoContent(http.StatusBadRequest) } - handle, _, avatarBytes, err := srv.fetchProfile(c.Request().Context(), did) + handle, _, _, avatarBytes, err := srv.fetchProfile(c.Request().Context(), did) if err != nil { return c.JSON(http.StatusBadGateway, echo.Map{"error": "ProfileFetchFailed"}) } @@ -198,9 +233,17 @@ func (srv *Server) buildPassAssets(theme, handle string, avatarBytes []byte) ([] logo2, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/logo@2x.png") logo3, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/logo@3x.png") - // Generic passes do not use a strip image. The whole pass renders as - // backgroundColor. avatarBytes is currently unused; reserved for a future - // design that adds a thumbnail. + // iOS 27 posterGeneric assets - shared across themes (no theme-specific + // variants yet). primaryLogo is the big butterfly to the right; background + // is the full-bleed dark navy. Optional - if missing, Wallet falls back to + // the iOS <= 26 generic layout that doesn't reference them. + primaryLogo2, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/primaryLogo@2x.png") + primaryLogo3, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/primaryLogo@3x.png") + background2, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/background@2x.png") + background3, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/background@3x.png") + + // avatarBytes is currently unused on this pass design; reserved for a + // future thumbnail composite. _ = avatarBytes assets := []PassAsset{ @@ -219,6 +262,18 @@ func (srv *Server) buildPassAssets(theme, handle string, avatarBytes []byte) ([] if len(logo3) > 0 { assets = append(assets, PassAsset{Name: "logo@3x.png", Data: logo3}) } + if len(primaryLogo2) > 0 { + assets = append(assets, PassAsset{Name: "primaryLogo@2x.png", Data: primaryLogo2}) + } + if len(primaryLogo3) > 0 { + assets = append(assets, PassAsset{Name: "primaryLogo@3x.png", Data: primaryLogo3}) + } + if len(background2) > 0 { + assets = append(assets, PassAsset{Name: "background@2x.png", Data: background2}) + } + if len(background3) > 0 { + assets = append(assets, PassAsset{Name: "background@3x.png", Data: background3}) + } return assets, nil } @@ -247,19 +302,32 @@ func decodeImage(data []byte) (image.Image, error) { return img, err } -func (srv *Server) fetchProfile(ctx context.Context, did string) (handle, displayName string, avatarBytes []byte, err error) { +func (srv *Server) fetchProfile(ctx context.Context, did string) (handle, displayName string, createdAt time.Time, avatarBytes []byte, err error) { pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, did) if err != nil { - return "", "", nil, err + return "", "", time.Time{}, nil, err } handle = pv.Handle if pv.DisplayName != nil { displayName = *pv.DisplayName } + // CreatedAt is when the profile record was written, which is usually within + // seconds of account creation - close enough for a "member since" display. + // Fall back to IndexedAt if CreatedAt is missing (older profiles). + if pv.CreatedAt != nil { + if t, terr := time.Parse(time.RFC3339, *pv.CreatedAt); terr == nil { + createdAt = t + } + } + if createdAt.IsZero() && pv.IndexedAt != nil { + if t, terr := time.Parse(time.RFC3339, *pv.IndexedAt); terr == nil { + createdAt = t + } + } if pv.Avatar != nil { // SSRF defense: only fetch https:// URLs if !strings.HasPrefix(*pv.Avatar, "https://") { - return handle, displayName, nil, nil + return handle, displayName, createdAt, nil, nil } req, _ := http.NewRequestWithContext(ctx, http.MethodGet, *pv.Avatar, nil) client := &http.Client{Timeout: 5 * time.Second} @@ -271,5 +339,5 @@ func (srv *Server) fetchProfile(ctx context.Context, did string) (handle, displa } } } - return handle, displayName, avatarBytes, nil + return handle, displayName, createdAt, avatarBytes, nil } diff --git a/bskyweb/cmd/bskyweb/invitepass_test.go b/bskyweb/cmd/bskyweb/invitepass_test.go index a3f95c1809..c2a2672157 100644 --- a/bskyweb/cmd/bskyweb/invitepass_test.go +++ b/bskyweb/cmd/bskyweb/invitepass_test.go @@ -12,14 +12,15 @@ import ( "github.com/labstack/echo/v4" ) -// stubAuth replaces the real ServerGetSession check; bound DID is returned to the handler. -type stubAuth struct{ did, handle string } +// stubAuth replaces the real ServerGetSession check; bound DID/handle/PDS are +// returned to the handler. +type stubAuth struct{ did, handle, pds string } -func (s stubAuth) Authenticate(c echo.Context) (did, handle string, err error) { +func (s stubAuth) Authenticate(c echo.Context) (did, handle, pdsHost string, err error) { if c.Request().Header.Get("Authorization") == "" { - return "", "", errAuthMissing + return "", "", "", errAuthMissing } - return s.did, s.handle, nil + return s.did, s.handle, s.pds, nil } func newTestServer(t *testing.T) (*Server, *stubAuth) { @@ -71,7 +72,7 @@ func TestWebInvitePassURL_OK(t *testing.T) { func TestWebInvitePassPkpass_TokenExpired(t *testing.T) { srv, _ := newTestServer(t) - expiredTok, _ := MintPassToken(srv.cfg.InvitePass.TokenSecret, "did:plc:abc", "dusk", + expiredTok, _ := MintPassToken(srv.cfg.InvitePass.TokenSecret, "did:plc:abc", "dusk", "", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), 60*time.Second) req := httptest.NewRequest(http.MethodGet, "/invite/pass.pkpass?theme=dusk&t="+expiredTok, nil) rec := httptest.NewRecorder() @@ -83,7 +84,7 @@ func TestWebInvitePassPkpass_TokenExpired(t *testing.T) { func TestWebInvitePassPkpass_ThemeMismatchInToken(t *testing.T) { srv, _ := newTestServer(t) - tok, _ := MintPassToken(srv.cfg.InvitePass.TokenSecret, "did:plc:abc", "day", time.Now(), 60*time.Second) + tok, _ := MintPassToken(srv.cfg.InvitePass.TokenSecret, "did:plc:abc", "day", "", time.Now(), 60*time.Second) req := httptest.NewRequest(http.MethodGet, "/invite/pass.pkpass?theme=dusk&t="+tok, nil) rec := httptest.NewRecorder() srv.echo.ServeHTTP(rec, req) diff --git a/bskyweb/cmd/bskyweb/passdata.go b/bskyweb/cmd/bskyweb/passdata.go index be43f272b6..ec994aa63e 100644 --- a/bskyweb/cmd/bskyweb/passdata.go +++ b/bskyweb/cmd/bskyweb/passdata.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "strings" + "time" ) var themeBgRGB = map[string]string{ @@ -29,11 +30,14 @@ func ThemeBackgroundRGB(theme string) string { } type passField struct { - Key string `json:"key"` - Label string `json:"label"` - Value string `json:"value"` + Key string `json:"key"` + Label string `json:"label"` + Value string `json:"value"` + DateStyle string `json:"dateStyle,omitempty"` } +// passFields is the iOS <= 26 storeCard/generic layout: header, primary, +// secondary, back. type passFields struct { HeaderFields []passField `json:"headerFields"` PrimaryFields []passField `json:"primaryFields"` @@ -41,6 +45,17 @@ type passFields struct { BackFields []passField `json:"backFields"` } +// posterFields is the iOS 27 layout used by Pass Designer. Adds auxiliary +// and footer field slots that the classic layout does not have. +type posterFields struct { + HeaderFields []passField `json:"headerFields"` + PrimaryFields []passField `json:"primaryFields"` + SecondaryFields []passField `json:"secondaryFields"` + AuxiliaryFields []passField `json:"auxiliaryFields"` + FooterFields []passField `json:"footerFields"` + BackFields []passField `json:"backFields"` +} + type barcode struct { Format string `json:"format"` Message string `json:"message"` @@ -49,23 +64,35 @@ type barcode struct { } type pkPass struct { - FormatVersion int `json:"formatVersion"` - PassTypeIdentifier string `json:"passTypeIdentifier"` - SerialNumber string `json:"serialNumber"` - TeamIdentifier string `json:"teamIdentifier"` - OrganizationName string `json:"organizationName"` - Description string `json:"description"` - LogoText string `json:"logoText"` - ForegroundColor string `json:"foregroundColor"` - LabelColor string `json:"labelColor"` - BackgroundColor string `json:"backgroundColor"` - Generic passFields `json:"generic"` - Barcodes []barcode `json:"barcodes"` + FormatVersion int `json:"formatVersion"` + PassTypeIdentifier string `json:"passTypeIdentifier"` + SerialNumber string `json:"serialNumber"` + TeamIdentifier string `json:"teamIdentifier"` + OrganizationName string `json:"organizationName"` + Description string `json:"description"` + LogoText string `json:"logoText"` + ForegroundColor string `json:"foregroundColor"` + LabelColor string `json:"labelColor"` + BackgroundColor string `json:"backgroundColor"` + Generic passFields `json:"generic"` + PosterGeneric posterFields `json:"posterGeneric"` + SuppressHeaderDarkening bool `json:"suppressHeaderDarkening"` + UseAutomaticColors bool `json:"useAutomaticColors"` + Barcodes []barcode `json:"barcodes"` } const passTypeIdentifier = "pass.xyz.blueskyweb.app" -func BuildPassJSON(did, handle, displayName, theme, teamID string) ([]byte, error) { +// BuildPassJSON builds the pkpass JSON for both the iOS <= 26 fallback layout +// (`generic` block) and the iOS 27 layout (`posterGeneric` block). The two +// blocks describe the same fields in the layouts each iOS version expects: +// the user sees the poster layout on iOS 27+ and the legacy layout below that. +// +// pdsHost is the hostname extracted from the user's DID document +// serviceEndpoint (e.g. "suillus.us-west.host.bsky.network"). +// createdAt is the user's profile record creation time, used as a rough +// stand-in for account creation. +func BuildPassJSON(did, handle, displayName, pdsHost, theme, teamID string, createdAt time.Time) ([]byte, error) { theme = CoerceTheme(theme) profileURL := "https://bsky.app/profile/" + handle atHandle := "@" + handle @@ -73,35 +100,55 @@ func BuildPassJSON(did, handle, displayName, theme, teamID string) ([]byte, erro if memberName == "" { memberName = atHandle } + if pdsHost == "" { + pdsHost = "bsky.social" + } + createdAtISO := "" + if !createdAt.IsZero() { + createdAtISO = createdAt.UTC().Format(time.RFC3339) + } + + pdsField := passField{Key: "pds", Label: "PDS", Value: pdsHost} + nameField := passField{Key: "name", Label: "NAME", Value: memberName} + didField := passField{Key: "did", Label: "DID", Value: did} + sinceField := passField{Key: "since", Label: "MEMBER SINCE", Value: createdAtISO, DateStyle: "PKDateStyleShort"} + backFields := []passField{ + {Key: "about", Label: "About", Value: "Scan the QR code to view this Bluesky profile."}, + {Key: "url", Label: "Profile URL", Value: profileURL}, + } + p := pkPass{ FormatVersion: 1, PassTypeIdentifier: passTypeIdentifier, SerialNumber: did + "-" + theme + "-v1", TeamIdentifier: teamID, OrganizationName: "Bluesky", - Description: "Bluesky invite - " + atHandle, - LogoText: "", + Description: "Bluesky profile - " + atHandle, + LogoText: "Bluesky", ForegroundColor: "rgb(255, 255, 255)", LabelColor: "rgb(255, 255, 255)", BackgroundColor: ThemeBackgroundRGB(theme), Generic: passFields{ - HeaderFields: []passField{ - {Key: "username", Label: "USERNAME", Value: atHandle}, - }, - PrimaryFields: []passField{ - {Key: "name", Label: "MEMBER NAME", Value: memberName}, - }, - SecondaryFields: []passField{}, - BackFields: []passField{ - {Key: "about", Label: "About", Value: "Scan the QR code to view this Bluesky profile."}, - {Key: "url", Label: "Profile URL", Value: profileURL}, - }, + HeaderFields: []passField{pdsField}, + PrimaryFields: []passField{nameField}, + SecondaryFields: []passField{didField, sinceField}, + BackFields: backFields, }, + PosterGeneric: posterFields{ + HeaderFields: []passField{pdsField}, + PrimaryFields: []passField{nameField}, + SecondaryFields: []passField{didField, sinceField}, + AuxiliaryFields: []passField{}, + FooterFields: []passField{}, + BackFields: backFields, + }, + SuppressHeaderDarkening: false, + UseAutomaticColors: false, Barcodes: []barcode{{ Format: "PKBarcodeFormatQR", Message: profileURL, MessageEncoding: "iso-8859-1", - AltText: atHandle, + AltText: handle, }}, } return json.MarshalIndent(p, "", " ") diff --git a/bskyweb/cmd/bskyweb/passdata_test.go b/bskyweb/cmd/bskyweb/passdata_test.go index ea7b886994..8f9380526f 100644 --- a/bskyweb/cmd/bskyweb/passdata_test.go +++ b/bskyweb/cmd/bskyweb/passdata_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestCoerceTheme(t *testing.T) { @@ -38,7 +39,8 @@ func TestThemeBackgroundRGB(t *testing.T) { func TestBuildPassJSON_Golden(t *testing.T) { for _, theme := range []string{"dawn", "day", "dusk", "night"} { t.Run(theme, func(t *testing.T) { - got, err := BuildPassJSON("did:plc:abc123", "alice.bsky.social", "Alice", theme, "TEAMID00") + createdAt := time.Date(2026, 1, 25, 23, 59, 5, 0, time.UTC) + got, err := BuildPassJSON("did:plc:abc123", "alice.bsky.social", "Alice", "alice.host.bsky.network", theme, "TEAMID00", createdAt) if err != nil { t.Fatalf("build: %v", err) } diff --git a/bskyweb/cmd/bskyweb/passtoken.go b/bskyweb/cmd/bskyweb/passtoken.go index 2bce1d4c6f..0dd0ff9e29 100644 --- a/bskyweb/cmd/bskyweb/passtoken.go +++ b/bskyweb/cmd/bskyweb/passtoken.go @@ -19,11 +19,12 @@ var ( type passTokenPayload struct { Did string `json:"d"` Theme string `json:"t"` + PDS string `json:"p,omitempty"` Exp int64 `json:"e"` } -func MintPassToken(secret []byte, did, theme string, now time.Time, ttl time.Duration) (string, error) { - p := passTokenPayload{Did: did, Theme: theme, Exp: now.Add(ttl).Unix()} +func MintPassToken(secret []byte, did, theme, pdsHost string, now time.Time, ttl time.Duration) (string, error) { + p := passTokenPayload{Did: did, Theme: theme, PDS: pdsHost, Exp: now.Add(ttl).Unix()} body, err := json.Marshal(p) if err != nil { return "", err @@ -35,27 +36,27 @@ func MintPassToken(secret []byte, did, theme string, now time.Time, ttl time.Dur return bodyB64 + "." + sig, nil } -func VerifyPassToken(secret []byte, token string, now time.Time) (string, string, error) { +func VerifyPassToken(secret []byte, token string, now time.Time) (did, theme, pdsHost string, err error) { parts := strings.SplitN(token, ".", 2) if len(parts) != 2 { - return "", "", ErrTokenInvalid + return "", "", "", ErrTokenInvalid } mac := hmac.New(sha256.New, secret) mac.Write([]byte(parts[0])) want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(want), []byte(parts[1])) { - return "", "", ErrTokenInvalid + return "", "", "", ErrTokenInvalid } - body, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - return "", "", ErrTokenInvalid + body, perr := base64.RawURLEncoding.DecodeString(parts[0]) + if perr != nil { + return "", "", "", ErrTokenInvalid } var p passTokenPayload - if err := json.Unmarshal(body, &p); err != nil { - return "", "", ErrTokenInvalid + if uerr := json.Unmarshal(body, &p); uerr != nil { + return "", "", "", ErrTokenInvalid } if now.Unix() > p.Exp { - return "", "", ErrTokenExpired + return "", "", "", ErrTokenExpired } - return p.Did, p.Theme, nil + return p.Did, p.Theme, p.PDS, nil } diff --git a/bskyweb/cmd/bskyweb/passtoken_test.go b/bskyweb/cmd/bskyweb/passtoken_test.go index 4b9fc46e4c..11d05b5772 100644 --- a/bskyweb/cmd/bskyweb/passtoken_test.go +++ b/bskyweb/cmd/bskyweb/passtoken_test.go @@ -10,24 +10,40 @@ import ( func TestPassToken_RoundTrip(t *testing.T) { secret := []byte("test-secret-32-bytes-of-random!!") now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) - tok, err := MintPassToken(secret, "did:plc:abc", "dusk", now, 60*time.Second) + tok, err := MintPassToken(secret, "did:plc:abc", "dusk", "suillus.us-west.host.bsky.network", now, 60*time.Second) if err != nil { t.Fatalf("mint: %v", err) } - gotDid, gotTheme, err := VerifyPassToken(secret, tok, now.Add(10*time.Second)) + gotDid, gotTheme, gotPDS, err := VerifyPassToken(secret, tok, now.Add(10*time.Second)) if err != nil { t.Fatalf("verify: %v", err) } - if gotDid != "did:plc:abc" || gotTheme != "dusk" { - t.Fatalf("mismatch: %q %q", gotDid, gotTheme) + if gotDid != "did:plc:abc" || gotTheme != "dusk" || gotPDS != "suillus.us-west.host.bsky.network" { + t.Fatalf("mismatch: %q %q %q", gotDid, gotTheme, gotPDS) + } +} + +func TestPassToken_RoundTripEmptyPDS(t *testing.T) { + secret := []byte("test-secret-32-bytes-of-random!!") + now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) + tok, err := MintPassToken(secret, "did:plc:abc", "day", "", now, 60*time.Second) + if err != nil { + t.Fatalf("mint: %v", err) + } + _, _, gotPDS, err := VerifyPassToken(secret, tok, now) + if err != nil { + t.Fatalf("verify: %v", err) + } + if gotPDS != "" { + t.Fatalf("expected empty PDS, got %q", gotPDS) } } func TestPassToken_Expired(t *testing.T) { secret := []byte("test-secret-32-bytes-of-random!!") now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) - tok, _ := MintPassToken(secret, "did:plc:abc", "day", now, 60*time.Second) - _, _, err := VerifyPassToken(secret, tok, now.Add(61*time.Second)) + tok, _ := MintPassToken(secret, "did:plc:abc", "day", "", now, 60*time.Second) + _, _, _, err := VerifyPassToken(secret, tok, now.Add(61*time.Second)) if !errors.Is(err, ErrTokenExpired) { t.Fatalf("want ErrTokenExpired, got %v", err) } @@ -37,8 +53,8 @@ func TestPassToken_BadSignature(t *testing.T) { secretA := []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") secretB := []byte("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) - tok, _ := MintPassToken(secretA, "did:plc:abc", "day", now, 60*time.Second) - _, _, err := VerifyPassToken(secretB, tok, now) + tok, _ := MintPassToken(secretA, "did:plc:abc", "day", "", now, 60*time.Second) + _, _, _, err := VerifyPassToken(secretB, tok, now) if !errors.Is(err, ErrTokenInvalid) { t.Fatalf("want ErrTokenInvalid, got %v", err) } @@ -47,11 +63,11 @@ func TestPassToken_BadSignature(t *testing.T) { func TestPassToken_Mutated(t *testing.T) { secret := []byte("test-secret-32-bytes-of-random!!") now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) - tok, _ := MintPassToken(secret, "did:plc:abc", "day", now, 60*time.Second) + tok, _ := MintPassToken(secret, "did:plc:abc", "day", "", now, 60*time.Second) // flip a byte in the middle of the payload portion mutated := []byte(tok) mutated[10] ^= 0x01 - _, _, err := VerifyPassToken(secret, string(mutated), now) + _, _, _, err := VerifyPassToken(secret, string(mutated), now) if !errors.Is(err, ErrTokenInvalid) { t.Fatalf("want ErrTokenInvalid, got %v", err) } diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 713f671d3d..e34aaa4838 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -198,7 +198,10 @@ func serve(cctx *cli.Context) error { }, }, } - server.authenticator = XrpcAuthenticator{BaseHost: appviewHost} + // Auth check (`com.atproto.server.getSession`) must go to a PDS, not the + // appview - the appview doesn't validate user accessJwts. bsky.social + // proxies getSession to each user's actual home PDS. + server.authenticator = XrpcAuthenticator{BaseHost: "https://bsky.social"} // Create the HTTP server. server.httpd = &http.Server{ diff --git a/bskyweb/cmd/bskyweb/testdata/pass-dawn.golden.json b/bskyweb/cmd/bskyweb/testdata/pass-dawn.golden.json index f89f3d78b2..01bbad242f 100644 --- a/bskyweb/cmd/bskyweb/testdata/pass-dawn.golden.json +++ b/bskyweb/cmd/bskyweb/testdata/pass-dawn.golden.json @@ -4,27 +4,39 @@ "serialNumber": "did:plc:abc123-dawn-v1", "teamIdentifier": "TEAMID00", "organizationName": "Bluesky", - "description": "Bluesky invite - @alice.bsky.social", - "logoText": "", + "description": "Bluesky profile - @alice.bsky.social", + "logoText": "Bluesky", "foregroundColor": "rgb(255, 255, 255)", "labelColor": "rgb(255, 255, 255)", "backgroundColor": "rgb(255, 109, 190)", "generic": { "headerFields": [ { - "key": "username", - "label": "USERNAME", - "value": "@alice.bsky.social" + "key": "pds", + "label": "PDS", + "value": "alice.host.bsky.network" } ], "primaryFields": [ { "key": "name", - "label": "MEMBER NAME", + "label": "NAME", "value": "Alice" } ], - "secondaryFields": [], + "secondaryFields": [ + { + "key": "did", + "label": "DID", + "value": "did:plc:abc123" + }, + { + "key": "since", + "label": "MEMBER SINCE", + "value": "2026-01-25T23:59:05Z", + "dateStyle": "PKDateStyleShort" + } + ], "backFields": [ { "key": "about", @@ -38,12 +50,57 @@ } ] }, + "posterGeneric": { + "headerFields": [ + { + "key": "pds", + "label": "PDS", + "value": "alice.host.bsky.network" + } + ], + "primaryFields": [ + { + "key": "name", + "label": "NAME", + "value": "Alice" + } + ], + "secondaryFields": [ + { + "key": "did", + "label": "DID", + "value": "did:plc:abc123" + }, + { + "key": "since", + "label": "MEMBER SINCE", + "value": "2026-01-25T23:59:05Z", + "dateStyle": "PKDateStyleShort" + } + ], + "auxiliaryFields": [], + "footerFields": [], + "backFields": [ + { + "key": "about", + "label": "About", + "value": "Scan the QR code to view this Bluesky profile." + }, + { + "key": "url", + "label": "Profile URL", + "value": "https://bsky.app/profile/alice.bsky.social" + } + ] + }, + "suppressHeaderDarkening": false, + "useAutomaticColors": false, "barcodes": [ { "format": "PKBarcodeFormatQR", "message": "https://bsky.app/profile/alice.bsky.social", "messageEncoding": "iso-8859-1", - "altText": "@alice.bsky.social" + "altText": "alice.bsky.social" } ] } \ No newline at end of file diff --git a/bskyweb/cmd/bskyweb/testdata/pass-day.golden.json b/bskyweb/cmd/bskyweb/testdata/pass-day.golden.json index 7cf5b26f18..48918d03a0 100644 --- a/bskyweb/cmd/bskyweb/testdata/pass-day.golden.json +++ b/bskyweb/cmd/bskyweb/testdata/pass-day.golden.json @@ -4,27 +4,39 @@ "serialNumber": "did:plc:abc123-day-v1", "teamIdentifier": "TEAMID00", "organizationName": "Bluesky", - "description": "Bluesky invite - @alice.bsky.social", - "logoText": "", + "description": "Bluesky profile - @alice.bsky.social", + "logoText": "Bluesky", "foregroundColor": "rgb(255, 255, 255)", "labelColor": "rgb(255, 255, 255)", "backgroundColor": "rgb(117, 175, 255)", "generic": { "headerFields": [ { - "key": "username", - "label": "USERNAME", - "value": "@alice.bsky.social" + "key": "pds", + "label": "PDS", + "value": "alice.host.bsky.network" } ], "primaryFields": [ { "key": "name", - "label": "MEMBER NAME", + "label": "NAME", "value": "Alice" } ], - "secondaryFields": [], + "secondaryFields": [ + { + "key": "did", + "label": "DID", + "value": "did:plc:abc123" + }, + { + "key": "since", + "label": "MEMBER SINCE", + "value": "2026-01-25T23:59:05Z", + "dateStyle": "PKDateStyleShort" + } + ], "backFields": [ { "key": "about", @@ -38,12 +50,57 @@ } ] }, + "posterGeneric": { + "headerFields": [ + { + "key": "pds", + "label": "PDS", + "value": "alice.host.bsky.network" + } + ], + "primaryFields": [ + { + "key": "name", + "label": "NAME", + "value": "Alice" + } + ], + "secondaryFields": [ + { + "key": "did", + "label": "DID", + "value": "did:plc:abc123" + }, + { + "key": "since", + "label": "MEMBER SINCE", + "value": "2026-01-25T23:59:05Z", + "dateStyle": "PKDateStyleShort" + } + ], + "auxiliaryFields": [], + "footerFields": [], + "backFields": [ + { + "key": "about", + "label": "About", + "value": "Scan the QR code to view this Bluesky profile." + }, + { + "key": "url", + "label": "Profile URL", + "value": "https://bsky.app/profile/alice.bsky.social" + } + ] + }, + "suppressHeaderDarkening": false, + "useAutomaticColors": false, "barcodes": [ { "format": "PKBarcodeFormatQR", "message": "https://bsky.app/profile/alice.bsky.social", "messageEncoding": "iso-8859-1", - "altText": "@alice.bsky.social" + "altText": "alice.bsky.social" } ] } \ No newline at end of file diff --git a/bskyweb/cmd/bskyweb/testdata/pass-dusk.golden.json b/bskyweb/cmd/bskyweb/testdata/pass-dusk.golden.json index 6166af1c2b..6ae6712586 100644 --- a/bskyweb/cmd/bskyweb/testdata/pass-dusk.golden.json +++ b/bskyweb/cmd/bskyweb/testdata/pass-dusk.golden.json @@ -4,27 +4,39 @@ "serialNumber": "did:plc:abc123-dusk-v1", "teamIdentifier": "TEAMID00", "organizationName": "Bluesky", - "description": "Bluesky invite - @alice.bsky.social", - "logoText": "", + "description": "Bluesky profile - @alice.bsky.social", + "logoText": "Bluesky", "foregroundColor": "rgb(255, 255, 255)", "labelColor": "rgb(255, 255, 255)", "backgroundColor": "rgb(177, 90, 162)", "generic": { "headerFields": [ { - "key": "username", - "label": "USERNAME", - "value": "@alice.bsky.social" + "key": "pds", + "label": "PDS", + "value": "alice.host.bsky.network" } ], "primaryFields": [ { "key": "name", - "label": "MEMBER NAME", + "label": "NAME", "value": "Alice" } ], - "secondaryFields": [], + "secondaryFields": [ + { + "key": "did", + "label": "DID", + "value": "did:plc:abc123" + }, + { + "key": "since", + "label": "MEMBER SINCE", + "value": "2026-01-25T23:59:05Z", + "dateStyle": "PKDateStyleShort" + } + ], "backFields": [ { "key": "about", @@ -38,12 +50,57 @@ } ] }, + "posterGeneric": { + "headerFields": [ + { + "key": "pds", + "label": "PDS", + "value": "alice.host.bsky.network" + } + ], + "primaryFields": [ + { + "key": "name", + "label": "NAME", + "value": "Alice" + } + ], + "secondaryFields": [ + { + "key": "did", + "label": "DID", + "value": "did:plc:abc123" + }, + { + "key": "since", + "label": "MEMBER SINCE", + "value": "2026-01-25T23:59:05Z", + "dateStyle": "PKDateStyleShort" + } + ], + "auxiliaryFields": [], + "footerFields": [], + "backFields": [ + { + "key": "about", + "label": "About", + "value": "Scan the QR code to view this Bluesky profile." + }, + { + "key": "url", + "label": "Profile URL", + "value": "https://bsky.app/profile/alice.bsky.social" + } + ] + }, + "suppressHeaderDarkening": false, + "useAutomaticColors": false, "barcodes": [ { "format": "PKBarcodeFormatQR", "message": "https://bsky.app/profile/alice.bsky.social", "messageEncoding": "iso-8859-1", - "altText": "@alice.bsky.social" + "altText": "alice.bsky.social" } ] } \ No newline at end of file diff --git a/bskyweb/cmd/bskyweb/testdata/pass-night.golden.json b/bskyweb/cmd/bskyweb/testdata/pass-night.golden.json index 572680b748..0076094636 100644 --- a/bskyweb/cmd/bskyweb/testdata/pass-night.golden.json +++ b/bskyweb/cmd/bskyweb/testdata/pass-night.golden.json @@ -4,27 +4,39 @@ "serialNumber": "did:plc:abc123-night-v1", "teamIdentifier": "TEAMID00", "organizationName": "Bluesky", - "description": "Bluesky invite - @alice.bsky.social", - "logoText": "", + "description": "Bluesky profile - @alice.bsky.social", + "logoText": "Bluesky", "foregroundColor": "rgb(255, 255, 255)", "labelColor": "rgb(255, 255, 255)", "backgroundColor": "rgb(0, 21, 51)", "generic": { "headerFields": [ { - "key": "username", - "label": "USERNAME", - "value": "@alice.bsky.social" + "key": "pds", + "label": "PDS", + "value": "alice.host.bsky.network" } ], "primaryFields": [ { "key": "name", - "label": "MEMBER NAME", + "label": "NAME", "value": "Alice" } ], - "secondaryFields": [], + "secondaryFields": [ + { + "key": "did", + "label": "DID", + "value": "did:plc:abc123" + }, + { + "key": "since", + "label": "MEMBER SINCE", + "value": "2026-01-25T23:59:05Z", + "dateStyle": "PKDateStyleShort" + } + ], "backFields": [ { "key": "about", @@ -38,12 +50,57 @@ } ] }, + "posterGeneric": { + "headerFields": [ + { + "key": "pds", + "label": "PDS", + "value": "alice.host.bsky.network" + } + ], + "primaryFields": [ + { + "key": "name", + "label": "NAME", + "value": "Alice" + } + ], + "secondaryFields": [ + { + "key": "did", + "label": "DID", + "value": "did:plc:abc123" + }, + { + "key": "since", + "label": "MEMBER SINCE", + "value": "2026-01-25T23:59:05Z", + "dateStyle": "PKDateStyleShort" + } + ], + "auxiliaryFields": [], + "footerFields": [], + "backFields": [ + { + "key": "about", + "label": "About", + "value": "Scan the QR code to view this Bluesky profile." + }, + { + "key": "url", + "label": "Profile URL", + "value": "https://bsky.app/profile/alice.bsky.social" + } + ] + }, + "suppressHeaderDarkening": false, + "useAutomaticColors": false, "barcodes": [ { "format": "PKBarcodeFormatQR", "message": "https://bsky.app/profile/alice.bsky.social", "messageEncoding": "iso-8859-1", - "altText": "@alice.bsky.social" + "altText": "alice.bsky.social" } ] } \ No newline at end of file diff --git a/bskyweb/pass.sh b/bskyweb/pass.sh new file mode 100755 index 0000000000..7bb62b86a1 --- /dev/null +++ b/bskyweb/pass.sh @@ -0,0 +1,9 @@ +HANDLE="thepope.dev" +read -s -p "App password: " PW; echo + +JWT=$(curl -s -X POST https://bsky.social/xrpc/com.atproto.server.createSession \ + -H "Content-Type: application/json" \ + -d "{\"identifier\":\"$HANDLE\",\"password\":\"$PW\"}" \ + | jq -r .accessJwt) + +echo "Got JWT (length: ${#JWT})" diff --git a/bskyweb/static/passes/background@2x.png b/bskyweb/static/passes/background@2x.png new file mode 100644 index 0000000000..e878ae6fe0 Binary files /dev/null and b/bskyweb/static/passes/background@2x.png differ diff --git a/bskyweb/static/passes/background@3x.png b/bskyweb/static/passes/background@3x.png new file mode 100644 index 0000000000..a955b833b4 Binary files /dev/null and b/bskyweb/static/passes/background@3x.png differ diff --git a/bskyweb/static/passes/primaryLogo@2x.png b/bskyweb/static/passes/primaryLogo@2x.png new file mode 100644 index 0000000000..ccf5a894ec Binary files /dev/null and b/bskyweb/static/passes/primaryLogo@2x.png differ diff --git a/bskyweb/static/passes/primaryLogo@3x.png b/bskyweb/static/passes/primaryLogo@3x.png new file mode 100644 index 0000000000..77329615a0 Binary files /dev/null and b/bskyweb/static/passes/primaryLogo@3x.png differ