|
| 1 | +package session |
| 2 | + |
| 3 | +import ( |
| 4 | + "net/http" |
| 5 | + "testing" |
| 6 | + "time" |
| 7 | + |
| 8 | + "github.com/lestrrat-go/jwx/v2/jwa" |
| 9 | + "github.com/lestrrat-go/jwx/v2/jwk" |
| 10 | + "github.com/lestrrat-go/jwx/v2/jws" |
| 11 | + "github.com/lestrrat-go/jwx/v2/jwt" |
| 12 | + . "github.com/smartystreets/goconvey/convey" |
| 13 | + |
| 14 | + "github.com/authgear/authgear-server/pkg/api/model" |
| 15 | + "github.com/authgear/authgear-server/pkg/util/clock" |
| 16 | + "github.com/authgear/authgear-server/pkg/util/jwtutil" |
| 17 | +) |
| 18 | + |
| 19 | +const testPrivateKeyPEM = `-----BEGIN PRIVATE KEY----- |
| 20 | +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC89eQDeH8icj6j |
| 21 | +1DUHTXKyhFkYOVrOVLA4xflDwqAuw5IrJQNgIjTsBZXrR1rh4BSBsjoE0ToH+/Da |
| 22 | +MfyAicQpv7QPI4pM8a/a3SY+rlr4j4LzFtchUvBMcGbSZZqKINBtxpAsFLPGFnwF |
| 23 | +NrxXIwrxE79cgY+g1KcmF8twqDmmash6fMoOeU8MTa8Q9Z7wTzhySeeZlBVFtvJp |
| 24 | +79Wqe75dtp0pe6E6ujavVjPifj2Msdl9RW7KhJsttgGhMGR2Jp07nAIBT150qX0G |
| 25 | +3gu0G5ILbgxcrhYZYK5fk/u6MQ0sAyXwS+fmppsPmYw6UVYlS2UGnaJlCE7Ml0e2 |
| 26 | +yyEyrbmnAgMBAAECggEARX7NsDUV1O5deVVnd1sVjvA78DvP2Miu0wKErVYcIXbO |
| 27 | +AE4pkqah/hgDzjc9BouqHxUUX4cvp5YSO71cl02TtqMJrvOsPqY4ve7NzQnE7Vui |
| 28 | +lpLU5i2hsQs51bGGh7yPy3/WsE+g2n6UeDpsREPgF0/i9ju0PjtXihwAN1u3cCt9 |
| 29 | +t9CsSGliHqQX9uO7o92yN+aROKEbw3x3gKpRJ/Gv3fQcVR01cXvaBrtdEb6kEVEB |
| 30 | +WBlCA0kmRc/H7jVYGcWqalLDjj99Pox47PLUigyJsNxJmMD881Ihah4zEQMpX7pW |
| 31 | +eRuyISTAA+i0MXO8+bypE6trglF8YQH6JTcVLTz70QKBgQDngFYD0gAqB41vMpmQ |
| 32 | +TGSr16qs63Q9QD0Ot9ZkSvYY745HvK7syLq6FLZl5Qz/f45NQ99BQtkGDZjE8sn9 |
| 33 | +W4V7/yA8xzNP+xmvdqsoOAcIO4j8W34dA6gS+z4h2u98LpqV9Q6ehbrZCPB8/MSn |
| 34 | +1QTnbINGw1ZCxfj6olN7ppaZaQKBgQDQ9RIHxHVhDHWpa83Fgf0oDve2UXD5YDsZ |
| 35 | +Axu6cYQOGCM7h0WxwDViIUuieWortYvGq8K1IlqfaDWlo5BHRXozmakMpJ4K8sBW |
| 36 | +F8TWn7PYw9cPH2XuZZHPnPiYkkhe0SoAifa3tk4bgyR5txOjdCr5L3ZFWfM7Vmkp |
| 37 | +hL2M7JTIjwKBgDXyShkJzs/8gpDvEan2o18IGtXA6I19cr0DSgqFDWQyLs24wmqb |
| 38 | +PCgwu3BzN9wyNU78CgKDOV+Xu4npqfhIY4rJoRGIugRhV1L0LF5q7/iTJxDnoTPR |
| 39 | +rlD+CzSIeFZP5eYb/RQjxa7dzmzR2mHh2gqz1sOesXNN/v8o5Jtj7qRBAoGAEibH |
| 40 | +yy7wt156th3sQRT6pckvEYJfmvoWCCUx+m8z9nl4TgqBLmCxAnY7+MAtTeC2ZKq0 |
| 41 | +/kEeuCw4RMxBkz9gzyyw960xIWhW9uOXsMEswU651tF2bFAca3mKSs6iRMJMsMFL |
| 42 | +Ukge3tr0hzI1HYTQ2taZooqey2/FMNscECrY/dcCgYEAuhBfEof+DCeuLmKgvks+ |
| 43 | +Idv5Ky2ZIR49L8VxCy7K+BXhr2vnKX6itlVDQVVpNIphdLHXQK6CNr8Ko5WinZHu |
| 44 | +gouLseU4p4zh8vYZcgPyqlLEdkygMCN0b0+HVaBTs0jlLGbvTC0Oiz69umYMe+5g |
| 45 | +eZDnqWNf7mYPdP5mO5iTtMw= |
| 46 | +-----END PRIVATE KEY----- |
| 47 | +` |
| 48 | + |
| 49 | +// buildTestJWKSet parses testPrivateKeyPEM into a JWK set usable for both |
| 50 | +// signing (private key) and verification (public key). |
| 51 | +func buildTestJWKSet(t *testing.T) (privateJWKSet jwk.Set, publicJWKSet jwk.Set) { |
| 52 | + t.Helper() |
| 53 | + set, err := jwk.Parse([]byte(testPrivateKeyPEM), jwk.WithPEM(true)) |
| 54 | + if err != nil { |
| 55 | + t.Fatal(err) |
| 56 | + } |
| 57 | + privKey, _ := set.Key(0) |
| 58 | + _ = privKey.Set(jwk.KeyIDKey, "test-key-1") |
| 59 | + _ = privKey.Set(jwk.AlgorithmKey, jwa.RS256.String()) |
| 60 | + |
| 61 | + pubSet, err := jwk.PublicSetOf(set) |
| 62 | + if err != nil { |
| 63 | + t.Fatal(err) |
| 64 | + } |
| 65 | + return set, pubSet |
| 66 | +} |
| 67 | + |
| 68 | +// signToken builds and signs a JWT with the given typ header and claims. |
| 69 | +func signToken(t *testing.T, privJWKSet jwk.Set, typ string, sub string, issuedAt time.Time, expiry time.Time) string { |
| 70 | + t.Helper() |
| 71 | + |
| 72 | + claims := jwt.New() |
| 73 | + _ = claims.Set(jwt.SubjectKey, sub) |
| 74 | + _ = claims.Set(jwt.IssuedAtKey, issuedAt.Unix()) |
| 75 | + _ = claims.Set(jwt.ExpirationKey, expiry.Unix()) |
| 76 | + _ = claims.Set(string(model.ClaimUserIsAnonymous), false) |
| 77 | + _ = claims.Set(string(model.ClaimUserIsVerified), true) |
| 78 | + _ = claims.Set(string(model.ClaimUserCanReauthenticate), true) |
| 79 | + _ = claims.Set(string(model.ClaimAuthgearRoles), []any{}) |
| 80 | + |
| 81 | + privKey, _ := privJWKSet.Key(0) |
| 82 | + |
| 83 | + hdr := jws.NewHeaders() |
| 84 | + _ = hdr.Set("typ", typ) |
| 85 | + |
| 86 | + signed, err := jwtutil.SignWithHeader(claims, hdr, jwa.RS256, privKey) |
| 87 | + if err != nil { |
| 88 | + t.Fatal(err) |
| 89 | + } |
| 90 | + return string(signed) |
| 91 | +} |
| 92 | + |
| 93 | +func makeHeader(token string) http.Header { |
| 94 | + h := http.Header{} |
| 95 | + h.Set("Authorization", "Bearer "+token) |
| 96 | + return h |
| 97 | +} |
| 98 | + |
| 99 | +func TestJWTToSessionInfo(t *testing.T) { |
| 100 | + Convey("jwtToSessionInfo", t, func() { |
| 101 | + privJWKSet, pubJWKSet := buildTestJWKSet(t) |
| 102 | + |
| 103 | + now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) |
| 104 | + m := &SessionInfoMiddleware{ |
| 105 | + Clock: clock.NewMockClockAtTime(now), |
| 106 | + } |
| 107 | + |
| 108 | + Convey("valid at+jwt access token returns valid session", func() { |
| 109 | + token := signToken(t, privJWKSet, "at+jwt", "user123", now.Add(-1*time.Minute), now.Add(1*time.Hour)) |
| 110 | + info := m.jwtToSessionInfo(pubJWKSet, makeHeader(token)) |
| 111 | + So(info.IsValid, ShouldBeTrue) |
| 112 | + So(info.UserID, ShouldEqual, "user123") |
| 113 | + So(info.UserVerified, ShouldBeTrue) |
| 114 | + So(info.UserCanReauthenticate, ShouldBeTrue) |
| 115 | + }) |
| 116 | + |
| 117 | + Convey("id_token with typ JWT is rejected", func() { |
| 118 | + token := signToken(t, privJWKSet, "JWT", "user123", now.Add(-1*time.Minute), now.Add(1*time.Hour)) |
| 119 | + info := m.jwtToSessionInfo(pubJWKSet, makeHeader(token)) |
| 120 | + So(info.IsValid, ShouldBeFalse) |
| 121 | + }) |
| 122 | + |
| 123 | + Convey("missing Authorization header returns invalid session", func() { |
| 124 | + info := m.jwtToSessionInfo(pubJWKSet, http.Header{}) |
| 125 | + So(info.IsValid, ShouldBeFalse) |
| 126 | + }) |
| 127 | + |
| 128 | + Convey("malformed Authorization header returns invalid session", func() { |
| 129 | + h := http.Header{} |
| 130 | + h.Set("Authorization", "notabearer") |
| 131 | + info := m.jwtToSessionInfo(pubJWKSet, h) |
| 132 | + So(info.IsValid, ShouldBeFalse) |
| 133 | + }) |
| 134 | + |
| 135 | + Convey("expired token returns invalid session", func() { |
| 136 | + token := signToken(t, privJWKSet, "at+jwt", "user123", now.Add(-2*time.Hour), now.Add(-1*time.Hour)) |
| 137 | + info := m.jwtToSessionInfo(pubJWKSet, makeHeader(token)) |
| 138 | + So(info.IsValid, ShouldBeFalse) |
| 139 | + }) |
| 140 | + |
| 141 | + Convey("token signed with a different key returns invalid session", func() { |
| 142 | + // Sign with our private key but verify against an empty JWK set — no matching key. |
| 143 | + emptyPubSet := jwk.NewSet() |
| 144 | + token := signToken(t, privJWKSet, "at+jwt", "user123", now.Add(-1*time.Minute), now.Add(1*time.Hour)) |
| 145 | + info := m.jwtToSessionInfo(emptyPubSet, makeHeader(token)) |
| 146 | + So(info.IsValid, ShouldBeFalse) |
| 147 | + }) |
| 148 | + }) |
| 149 | +} |
0 commit comments