feat(bskyweb): build Google Wallet save-to-wallet JWT
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type WalletConfig struct {
|
||||
IssuerEmail string
|
||||
IssuerID string
|
||||
PrivateKey *rsa.PrivateKey
|
||||
HeroBaseURL string
|
||||
LogoURL string
|
||||
}
|
||||
|
||||
var hexBgByTheme = map[string]string{
|
||||
"dawn": "#ff6dbe",
|
||||
"day": "#75afff",
|
||||
"dusk": "#b15aa2",
|
||||
"night": "#001533",
|
||||
}
|
||||
|
||||
func LoadWalletConfig(serviceAccountJSON []byte, issuerID, heroBaseURL, logoURL string) (*WalletConfig, error) {
|
||||
var sa struct {
|
||||
ClientEmail string `json:"client_email"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
if err := json.Unmarshal(serviceAccountJSON, &sa); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode([]byte(sa.PrivateKey))
|
||||
if block == nil {
|
||||
return nil, errors.New("service account private_key: no PEM block")
|
||||
}
|
||||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsaKey, ok := k.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("service account key is not RSA")
|
||||
}
|
||||
return &WalletConfig{
|
||||
IssuerEmail: sa.ClientEmail,
|
||||
IssuerID: issuerID,
|
||||
PrivateKey: rsaKey,
|
||||
HeroBaseURL: heroBaseURL,
|
||||
LogoURL: logoURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildSaveJWT(cfg *WalletConfig, did, handle, theme string) (string, error) {
|
||||
theme = CoerceTheme(theme)
|
||||
profileURL := "https://bsky.app/profile/" + handle
|
||||
hexBg := hexBgByTheme[theme]
|
||||
heroQuery := url.Values{"did": {did}, "theme": {theme}}
|
||||
heroURL := cfg.HeroBaseURL + "?" + heroQuery.Encode()
|
||||
|
||||
obj := map[string]any{
|
||||
"id": cfg.IssuerID + ".bsky-" + did + "-" + theme,
|
||||
"classId": cfg.IssuerID + ".bsky_invite_v1",
|
||||
"logo": map[string]any{
|
||||
"sourceUri": map[string]any{"uri": cfg.LogoURL},
|
||||
},
|
||||
"cardTitle": langValue("Bluesky"),
|
||||
"header": langValue("@" + handle),
|
||||
"subheader": langValue("bsky.app/profile/" + handle),
|
||||
"hexBackgroundColor": hexBg,
|
||||
"heroImage": map[string]any{"sourceUri": map[string]any{"uri": heroURL}},
|
||||
"barcode": map[string]any{
|
||||
"type": "QR_CODE",
|
||||
"value": profileURL,
|
||||
"alternateText": "@" + handle,
|
||||
},
|
||||
"linksModuleData": map[string]any{
|
||||
"uris": []any{map[string]any{"uri": profileURL, "description": "Open profile"}},
|
||||
},
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"iss": cfg.IssuerEmail,
|
||||
"aud": "google",
|
||||
"typ": "savetowallet",
|
||||
"payload": map[string]any{
|
||||
"genericObjects": []any{obj},
|
||||
},
|
||||
}
|
||||
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return tok.SignedString(cfg.PrivateKey)
|
||||
}
|
||||
|
||||
func langValue(s string) map[string]any {
|
||||
return map[string]any{"defaultValue": map[string]any{"language": "en", "value": s}}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestBuildSaveJWT_RoundTrip(t *testing.T) {
|
||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
cfg := &WalletConfig{
|
||||
IssuerEmail: "issuer@example.com",
|
||||
IssuerID: "3388000000000000000",
|
||||
PrivateKey: key,
|
||||
HeroBaseURL: "https://bsky.app/invite/wallet/hero",
|
||||
LogoURL: "https://web-cdn.bsky.app/passes/logo.png",
|
||||
}
|
||||
|
||||
tok, err := BuildSaveJWT(cfg, "did:plc:abc", "alice.bsky.social", "dusk")
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := jwt.Parse(tok, func(t *jwt.Token) (any, error) {
|
||||
return &key.PublicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
claims := parsed.Claims.(jwt.MapClaims)
|
||||
if claims["iss"] != "issuer@example.com" {
|
||||
t.Errorf("iss = %v", claims["iss"])
|
||||
}
|
||||
if claims["aud"] != "google" {
|
||||
t.Errorf("aud = %v", claims["aud"])
|
||||
}
|
||||
if claims["typ"] != "savetowallet" {
|
||||
t.Errorf("typ = %v", claims["typ"])
|
||||
}
|
||||
payload := claims["payload"].(map[string]any)
|
||||
objs := payload["genericObjects"].([]any)
|
||||
if len(objs) != 1 {
|
||||
t.Fatalf("want 1 object, got %d", len(objs))
|
||||
}
|
||||
obj := objs[0].(map[string]any)
|
||||
if !strings.HasSuffix(obj["id"].(string), ".bsky-did:plc:abc-dusk") {
|
||||
t.Errorf("unexpected id: %v", obj["id"])
|
||||
}
|
||||
if obj["hexBackgroundColor"] != "#b15aa2" {
|
||||
t.Errorf("hexBackgroundColor = %v", obj["hexBackgroundColor"])
|
||||
}
|
||||
barcode := obj["barcode"].(map[string]any)
|
||||
if barcode["value"] != "https://bsky.app/profile/alice.bsky.social" {
|
||||
t.Errorf("barcode.value = %v", barcode["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSaveJWT_ThemeCoerced(t *testing.T) {
|
||||
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
cfg := &WalletConfig{IssuerEmail: "x", IssuerID: "Y", PrivateKey: key}
|
||||
tok, err := BuildSaveJWT(cfg, "did:plc:abc", "alice.bsky.social", "GARBAGE")
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
parsed, _ := jwt.Parse(tok, func(t *jwt.Token) (any, error) { return &key.PublicKey, nil })
|
||||
obj := parsed.Claims.(jwt.MapClaims)["payload"].(map[string]any)["genericObjects"].([]any)[0].(map[string]any)
|
||||
if !strings.HasSuffix(obj["id"].(string), "-day") {
|
||||
t.Errorf("expected coercion to day, got id %v", obj["id"])
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ go 1.26
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c
|
||||
github.com/flosch/pongo2/v6 v6.0.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/ipfs/go-log v1.0.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/klauspost/compress v1.18.0
|
||||
|
||||
@@ -31,6 +31,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
|
||||
Reference in New Issue
Block a user