feat(bskyweb): iOS 27 posterGeneric layout with DID, PDS, MEMBER SINCE
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "", " ")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+9
@@ -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 |
Reference in New Issue
Block a user