package auth import ( "crypto/rand" "encoding/base64" "fmt" "time" "github.com/bluesky-social/indigo/atproto/atcrypto" indigoauth "github.com/bluesky-social/indigo/atproto/auth" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/golang-jwt/jwt/v5" ) func randomNonce() string { buf := make([]byte, 16) _, _ = rand.Read(buf) return base64.RawURLEncoding.EncodeToString(buf) } // AppviewLxm is the lexicon method this token binds to. Hold doesn't enforce // lxm today (we just trust the signature), but appview sends it for forward // compatibility and so the JWT carries an unambiguous purpose. // // Kept as a typed NSID rather than a string so callers can't mistype it. var AppviewLxm = syntax.NSID("io.atcr.hold.updateCrewTier") // appviewServiceClaims extends RegisteredClaims with the `lxm` claim that // indigo's SignServiceAuth uses. We define our own struct because we need to // keep Subject (hold's verifier reads it as the acting user DID). type appviewServiceClaims struct { jwt.RegisteredClaims LexMethod string `json:"lxm,omitempty"` } // CreateAppviewServiceToken creates a short-lived JWT for appview→hold // communication, signed with the appview's private key. // // Supports both P-256 (ES256) and K-256/secp256k1 (ES256K) keys — the // algorithm is selected from the concrete key type. // // Claims: // - iss: appview DID // - aud: hold DID // - sub: user DID being acted upon // - exp: now + 60s // - iat: now // - jti: random 16-byte nonce (replay prevention) // - lxm: lexicon method this token authorizes // // Signing uses indigo's atproto JWT signing method so the atcrypto key is // used natively (no PKCS8 round-trip). func CreateAppviewServiceToken(privateKey atcrypto.PrivateKey, appviewDID, holdDID, userDID string) (string, error) { var alg string switch privateKey.(type) { case *atcrypto.PrivateKeyP256: alg = "ES256" case *atcrypto.PrivateKeyK256: alg = "ES256K" default: return "", fmt.Errorf("unsupported appview key type %T (expected P-256 or K-256)", privateKey) } now := time.Now() claims := appviewServiceClaims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: appviewDID, Audience: jwt.ClaimStrings{holdDID}, Subject: userDID, ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)), IssuedAt: jwt.NewNumericDate(now), ID: randomNonce(), }, LexMethod: AppviewLxm.String(), } sm := jwt.GetSigningMethod(alg) if sm == nil { return "", fmt.Errorf("%s signing method not registered", alg) } token := jwt.NewWithClaims(sm, claims) signed, err := token.SignedString(privateKey) if err != nil { return "", fmt.Errorf("failed to sign token: %w", err) } return signed, nil } // keep indigoauth referenced so its init() runs and overrides ES256/ES256K // signing methods to accept atcrypto.PrivateKey directly. var _ = indigoauth.SignServiceAuth