diff --git a/.changeset/auth-verify-issued-at.md b/.changeset/auth-verify-issued-at.md new file mode 100644 index 000000000..6b10ef9ee --- /dev/null +++ b/.changeset/auth-verify-issued-at.md @@ -0,0 +1,6 @@ +--- +"github.com/livekit/protocol": patch +"@livekit/protocol": patch +--- + +Validate the `iat` claim when verifying access tokens (`auth.APIKeyTokenVerifier.Verify`). Previously, a correctly-signed token that omitted `nbf` and carried a far-future `iat` would verify successfully immediately, regardless of how far in the future it claimed to have been issued. diff --git a/auth/verifier.go b/auth/verifier.go index 60a476d35..8e615712f 100644 --- a/auth/verifier.go +++ b/auth/verifier.go @@ -82,6 +82,13 @@ func (v *APIKeyTokenVerifier) Verify(key interface{}) (*jwt.RegisteredClaims, *C // or third-party minter that forgets it silently mints a permanent // credential. jwt.WithExpirationRequired(), + // Without this, iat is never validated. First-party SDKs always set + // nbf (see AccessToken.ToJWT), which the parser already validates by + // default when present, but a token that omits nbf entirely verifies + // immediately regardless of how far in the future it claims to have + // been issued unless iat is checked too. (A token omitting both iat + // and nbf is unaffected by this check either way.) + jwt.WithIssuedAt(), ) if err != nil { return nil, nil, err diff --git a/auth/verifier_test.go b/auth/verifier_test.go index dd5d23c71..28414a0a9 100644 --- a/auth/verifier_test.go +++ b/auth/verifier_test.go @@ -69,6 +69,31 @@ func TestVerifier(t *testing.T) { require.Error(t, err) }) + t.Run("token issued in the future without nbf is rejected", func(t *testing.T) { + // hand-rolled JWT with iat 2h in the future and no nbf claim. The Go + // SDK always sets nbf (see AccessToken.ToJWT), so build this directly + // to model a third-party minter that omits it: without nbf, and + // without WithIssuedAt() on the parser, nothing stops a token from + // verifying immediately no matter how far in the future it claims to + // have been issued. + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": apiKey, + "iat": jwt.NewNumericDate(time.Now().Add(2 * time.Hour)), + "exp": jwt.NewNumericDate(time.Now().Add(3 * time.Hour)), + "video": map[string]interface{}{ + "roomCreate": true, + }, + }) + authToken, err := token.SignedString([]byte(secret)) + require.NoError(t, err) + + v, err := auth.ParseAPIToken(authToken) + require.NoError(t, err) + + _, _, err = v.Verify(secret) + require.Error(t, err) + }) + t.Run("unexpired token is verified", func(t *testing.T) { claim := auth.VideoGrant{RoomCreate: true} at := auth.NewAccessToken(apiKey, secret).