feat(bskyweb): iOS 27 posterGeneric layout with DID, PDS, MEMBER SINCE

This commit is contained in:
vineyardbovines
2026-06-26 10:43:30 -04:00
parent 93bd2567ce
commit 1b6615b492
16 changed files with 490 additions and 115 deletions
+90 -22
View File
@@ -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
}
+8 -7
View File
@@ -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)
+77 -30
View File
@@ -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, "", " ")
+3 -1
View File
@@ -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)
}
+13 -12
View File
@@ -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
}
+26 -10
View File
@@ -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)
}
+4 -1
View File
@@ -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{
+65 -8
View File
@@ -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"
}
]
}
+65 -8
View File
@@ -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"
}
]
}
+65 -8
View File
@@ -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"
}
]
}
+65 -8
View File
@@ -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"
}
]
}
+9
View File
@@ -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})"
Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB