package auth import ( "testing" "time" "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/golang-jwt/jwt/v5" ) // TestCreateAppviewServiceToken_Claims locks the claim shape so we can detect // regressions when swapping the underlying signer. func TestCreateAppviewServiceToken_Claims(t *testing.T) { priv, err := atcrypto.GeneratePrivateKeyP256() if err != nil { t.Fatalf("generate key: %v", err) } const ( appviewDID = "did:web:atcr.io" holdDID = "did:web:hold01.atcr.io" userDID = "did:plc:pddp4xt5lgnv2qsegbzzs4xg" ) before := time.Now() tokenStr, err := CreateAppviewServiceToken(priv, appviewDID, holdDID, userDID) if err != nil { t.Fatalf("CreateAppviewServiceToken: %v", err) } after := time.Now() parser := jwt.NewParser(jwt.WithoutClaimsValidation()) parsed, _, err := parser.ParseUnverified(tokenStr, jwt.MapClaims{}) if err != nil { t.Fatalf("parse token: %v", err) } if parsed.Method.Alg() != "ES256" { t.Errorf("alg = %q, want ES256", parsed.Method.Alg()) } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok { t.Fatalf("claims type %T", parsed.Claims) } if got, _ := claims["iss"].(string); got != appviewDID { t.Errorf("iss = %q, want %q", got, appviewDID) } aud := claims["aud"] switch a := aud.(type) { case string: if a != holdDID { t.Errorf("aud = %q, want %q", a, holdDID) } case []any: if len(a) != 1 || a[0] != holdDID { t.Errorf("aud = %v, want [%q]", a, holdDID) } default: t.Errorf("aud unexpected type %T = %v", aud, aud) } exp, err := claims.GetExpirationTime() if err != nil || exp == nil { t.Fatalf("exp missing: %v", err) } delta := exp.Sub(before) if delta < 55*time.Second || delta > 65*time.Second { t.Errorf("exp delta from sign time = %v, want ~60s", delta) } iat, err := claims.GetIssuedAt() if err != nil || iat == nil { t.Fatalf("iat missing: %v", err) } if iat.Before(before.Add(-1*time.Second)) || iat.After(after.Add(1*time.Second)) { t.Errorf("iat = %v, outside sign window [%v, %v]", iat.Time, before, after) } if got, _ := claims["sub"].(string); got != userDID { t.Errorf("sub = %q, want %q", got, userDID) } if got, _ := claims["lxm"].(string); got != AppviewLxm.String() { t.Errorf("lxm = %q, want %q", got, AppviewLxm.String()) } if got, _ := claims["jti"].(string); got == "" { t.Error("jti claim missing — required for replay protection") } } // TestCreateAppviewServiceToken_K256UsesES256K — when given a secp256k1 key, // the token must be signed with ES256K so K-256-only appviews (legacy ATProto // PDS keys) can still authenticate to hold. func TestCreateAppviewServiceToken_K256UsesES256K(t *testing.T) { priv, err := atcrypto.GeneratePrivateKeyK256() if err != nil { t.Fatalf("generate K-256: %v", err) } tok, err := CreateAppviewServiceToken(priv, "did:web:atcr.io", "did:web:hold01.atcr.io", "did:plc:pddp4xt5lgnv2qsegbzzs4xg") if err != nil { t.Fatalf("sign: %v", err) } parser := jwt.NewParser(jwt.WithoutClaimsValidation()) parsed, _, err := parser.ParseUnverified(tok, jwt.MapClaims{}) if err != nil { t.Fatalf("parse: %v", err) } if parsed.Method.Alg() != "ES256K" { t.Errorf("alg = %q, want ES256K", parsed.Method.Alg()) } } // TestCreateAppviewServiceToken_HasJTI — after the swap to indigo's // SignServiceAuth, every token carries a non-empty `jti` (random nonce) so // hold can reject replays. Two consecutive tokens get distinct jtis. func TestCreateAppviewServiceToken_HasJTI(t *testing.T) { priv, err := atcrypto.GeneratePrivateKeyP256() if err != nil { t.Fatalf("generate key: %v", err) } const ( appviewDID = "did:web:atcr.io" holdDID = "did:web:hold01.atcr.io" userDID = "did:plc:pddp4xt5lgnv2qsegbzzs4xg" ) parser := jwt.NewParser(jwt.WithoutClaimsValidation()) jti := func(t *testing.T) string { t.Helper() s, err := CreateAppviewServiceToken(priv, appviewDID, holdDID, userDID) if err != nil { t.Fatalf("sign: %v", err) } tok, _, err := parser.ParseUnverified(s, jwt.MapClaims{}) if err != nil { t.Fatalf("parse: %v", err) } claims, _ := tok.Claims.(jwt.MapClaims) val, _ := claims["jti"].(string) return val } a := jti(t) if a == "" { t.Fatal("jti claim missing or empty — needed for replay prevention") } b := jti(t) if a == b { t.Errorf("two tokens share jti %q; expected distinct nonces", a) } }